this repo has no description

Initial commit

+8
.gitignore
··· 1 + .DS_Store 2 + /.build 3 + /Packages 4 + xcuserdata/ 5 + DerivedData/ 6 + .swiftpm/configuration/registries.json 7 + .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 + .netrc
+133
CODE_OF_CONDUCT.md
··· 1 + 2 + # Contributor Covenant Code of Conduct 3 + 4 + ## Our Pledge 5 + 6 + We as members, contributors, and leaders pledge to make participation in our 7 + community a harassment-free experience for everyone, regardless of age, body 8 + size, visible or invisible disability, ethnicity, sex characteristics, gender 9 + identity and expression, level of experience, education, socio-economic status, 10 + nationality, personal appearance, race, caste, color, religion, or sexual 11 + identity and orientation. 12 + 13 + We pledge to act and interact in ways that contribute to an open, welcoming, 14 + diverse, inclusive, and healthy community. 15 + 16 + ## Our Standards 17 + 18 + Examples of behavior that contributes to a positive environment for our 19 + community include: 20 + 21 + * Demonstrating empathy and kindness toward other people 22 + * Being respectful of differing opinions, viewpoints, and experiences 23 + * Giving and gracefully accepting constructive feedback 24 + * Accepting responsibility and apologizing to those affected by our mistakes, 25 + and learning from the experience 26 + * Focusing on what is best not just for us as individuals, but for the overall 27 + community 28 + 29 + Examples of unacceptable behavior include: 30 + 31 + * The use of sexualized language or imagery, and sexual attention or advances of 32 + any kind 33 + * Trolling, insulting or derogatory comments, and personal or political attacks 34 + * Public or private harassment 35 + * Publishing others' private information, such as a physical or email address, 36 + without their explicit permission 37 + * Other conduct which could reasonably be considered inappropriate in a 38 + professional setting 39 + 40 + ## Enforcement Responsibilities 41 + 42 + Community leaders are responsible for clarifying and enforcing our standards of 43 + acceptable behavior and will take appropriate and fair corrective action in 44 + response to any behavior that they deem inappropriate, threatening, offensive, 45 + or harmful. 46 + 47 + Community leaders have the right and responsibility to remove, edit, or reject 48 + comments, commits, code, wiki edits, issues, and other contributions that are 49 + not aligned to this Code of Conduct, and will communicate reasons for moderation 50 + decisions when appropriate. 51 + 52 + ## Scope 53 + 54 + This Code of Conduct applies within all community spaces, and also applies when 55 + an individual is officially representing the community in public spaces. 56 + Examples of representing our community include using an official e-mail address, 57 + posting via an official social media account, or acting as an appointed 58 + representative at an online or offline event. 59 + 60 + ## Enforcement 61 + 62 + Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 + reported to the community leaders responsible for enforcement at 64 + contact@sparrowtek.com. 65 + All complaints will be reviewed and investigated promptly and fairly. 66 + 67 + All community leaders are obligated to respect the privacy and security of the 68 + reporter of any incident. 69 + 70 + ## Enforcement Guidelines 71 + 72 + Community leaders will follow these Community Impact Guidelines in determining 73 + the consequences for any action they deem in violation of this Code of Conduct: 74 + 75 + ### 1. Correction 76 + 77 + **Community Impact**: Use of inappropriate language or other behavior deemed 78 + unprofessional or unwelcome in the community. 79 + 80 + **Consequence**: A private, written warning from community leaders, providing 81 + clarity around the nature of the violation and an explanation of why the 82 + behavior was inappropriate. A public apology may be requested. 83 + 84 + ### 2. Warning 85 + 86 + **Community Impact**: A violation through a single incident or series of 87 + actions. 88 + 89 + **Consequence**: A warning with consequences for continued behavior. No 90 + interaction with the people involved, including unsolicited interaction with 91 + those enforcing the Code of Conduct, for a specified period of time. This 92 + includes avoiding interactions in community spaces as well as external channels 93 + like social media. Violating these terms may lead to a temporary or permanent 94 + ban. 95 + 96 + ### 3. Temporary Ban 97 + 98 + **Community Impact**: A serious violation of community standards, including 99 + sustained inappropriate behavior. 100 + 101 + **Consequence**: A temporary ban from any sort of interaction or public 102 + communication with the community for a specified period of time. No public or 103 + private interaction with the people involved, including unsolicited interaction 104 + with those enforcing the Code of Conduct, is allowed during this period. 105 + Violating these terms may lead to a permanent ban. 106 + 107 + ### 4. Permanent Ban 108 + 109 + **Community Impact**: Demonstrating a pattern of violation of community 110 + standards, including sustained inappropriate behavior, harassment of an 111 + individual, or aggression toward or disparagement of classes of individuals. 112 + 113 + **Consequence**: A permanent ban from any sort of public interaction within the 114 + community. 115 + 116 + ## Attribution 117 + 118 + This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 + version 2.1, available at 120 + [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 + 122 + Community Impact Guidelines were inspired by 123 + [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 + 125 + For answers to common questions about this code of conduct, see the FAQ at 126 + [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 + [https://www.contributor-covenant.org/translations][translations]. 128 + 129 + [homepage]: https://www.contributor-covenant.org 130 + [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 + [Mozilla CoC]: https://github.com/mozilla/diversity 132 + [FAQ]: https://www.contributor-covenant.org/faq 133 + [translations]: https://www.contributor-covenant.org/translations
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 SparrowTek 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+69
Package.resolved
··· 1 + { 2 + "originHash" : "588bff50c2acc1e7fc8a48e4cd7e69605871ec12cb138ce96710e0b88cebb635", 3 + "pins" : [ 4 + { 5 + "identity" : "coreatprotocol", 6 + "kind" : "remoteSourceControl", 7 + "location" : "https://tangled.org/@sparrowtek.com/CoreATProtocol", 8 + "state" : { 9 + "branch" : "main", 10 + "revision" : "df2572331f02660378b0c09005b0bac7d39041d2" 11 + } 12 + }, 13 + { 14 + "identity" : "jwt-kit", 15 + "kind" : "remoteSourceControl", 16 + "location" : "https://github.com/vapor/jwt-kit.git", 17 + "state" : { 18 + "revision" : "b5f82fb9dc238f2fcac53d721a222513a152613c", 19 + "version" : "5.3.0" 20 + } 21 + }, 22 + { 23 + "identity" : "oauthenticator", 24 + "kind" : "remoteSourceControl", 25 + "location" : "https://github.com/radmakr/OAuthenticator.git", 26 + "state" : { 27 + "branch" : "CoreAtProtocol", 28 + "revision" : "e382a28c7f7dbdb36ec358b1324e0d2320249c70" 29 + } 30 + }, 31 + { 32 + "identity" : "swift-asn1", 33 + "kind" : "remoteSourceControl", 34 + "location" : "https://github.com/apple/swift-asn1.git", 35 + "state" : { 36 + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", 37 + "version" : "1.5.1" 38 + } 39 + }, 40 + { 41 + "identity" : "swift-certificates", 42 + "kind" : "remoteSourceControl", 43 + "location" : "https://github.com/apple/swift-certificates.git", 44 + "state" : { 45 + "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", 46 + "version" : "1.17.0" 47 + } 48 + }, 49 + { 50 + "identity" : "swift-crypto", 51 + "kind" : "remoteSourceControl", 52 + "location" : "https://github.com/apple/swift-crypto.git", 53 + "state" : { 54 + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", 55 + "version" : "4.2.0" 56 + } 57 + }, 58 + { 59 + "identity" : "swift-log", 60 + "kind" : "remoteSourceControl", 61 + "location" : "https://github.com/apple/swift-log.git", 62 + "state" : { 63 + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", 64 + "version" : "1.8.0" 65 + } 66 + } 67 + ], 68 + "version" : 3 69 + }
+35
Package.swift
··· 1 + // swift-tools-version: 6.2 2 + 3 + import PackageDescription 4 + 5 + let package = Package( 6 + name: "bskyKit", 7 + platforms: [ 8 + .iOS(.v26), 9 + .watchOS(.v26), 10 + .tvOS(.v26), 11 + .macOS(.v26), 12 + .macCatalyst(.v26), 13 + ], 14 + products: [ 15 + .library( 16 + name: "bskyKit", 17 + targets: ["bskyKit"] 18 + ), 19 + ], 20 + dependencies: [ 21 + .package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"), 22 + ], 23 + targets: [ 24 + .target( 25 + name: "bskyKit", 26 + dependencies: [ 27 + "CoreATProtocol", 28 + ], 29 + ), 30 + .testTarget( 31 + name: "bskyKitTests", 32 + dependencies: ["bskyKit"] 33 + ), 34 + ] 35 + )
+385
README.md
··· 1 + # bskyKit 2 + 3 + A Swift SDK for building [Bluesky](https://bsky.app) clients on Apple platforms. bskyKit provides a complete, type-safe interface to the Bluesky API with modern Swift concurrency support. 4 + 5 + ## Overview 6 + 7 + bskyKit implements the `app.bsky.*` lexicons for the AT Protocol, giving you everything needed to build a full-featured Bluesky client: 8 + 9 + - **Read Operations** - Fetch timelines, profiles, posts, threads, notifications, and social graphs 10 + - **Write Operations** - Create posts, likes, reposts, follows, and blocks 11 + - **Rich Text** - Automatic detection and creation of mentions, links, and hashtags with proper byte indexing 12 + - **Type Safety** - Fully typed models for all API responses with `Codable` and `Sendable` conformance 13 + - **SwiftUI Ready** - Models conform to `Identifiable` for seamless use in SwiftUI lists 14 + 15 + Built on [CoreATProtocol](https://tangled.org/@sparrowtek.com/CoreATProtocol) for networking, authentication, and token management. 16 + 17 + ## Requirements 18 + 19 + - Swift 6.2+ 20 + - iOS 26.0+ / macOS 26.0+ / watchOS 26.0+ / tvOS 26.0+ / Mac Catalyst 26.0+ 21 + 22 + ## Installation 23 + 24 + ### Swift Package Manager 25 + 26 + Add bskyKit to your `Package.swift` dependencies: 27 + 28 + ```swift 29 + dependencies: [ 30 + .package(url: "https://tangled.org/@sparrowtek.com/bskyKit", branch: "main"), 31 + ] 32 + ``` 33 + 34 + Then add it to your target: 35 + 36 + ```swift 37 + .target( 38 + name: "YourApp", 39 + dependencies: ["bskyKit"] 40 + ), 41 + ``` 42 + 43 + Or in Xcode: **File > Add Package Dependencies** and enter: 44 + ``` 45 + https://tangled.org/@sparrowtek.com/bskyKit 46 + ``` 47 + 48 + ## Quick Start 49 + 50 + ### Setup 51 + 52 + Configure the environment before making any API calls: 53 + 54 + ```swift 55 + import bskyKit 56 + import CoreATProtocol 57 + 58 + // Configure with your PDS host and authentication tokens 59 + await setup( 60 + hostURL: "https://bsky.social", 61 + accessJWT: "your-access-token", 62 + refreshJWT: "your-refresh-token" 63 + ) 64 + ``` 65 + 66 + ### Reading Data 67 + 68 + Use `BskyService` for all read operations: 69 + 70 + ```swift 71 + let service = await BskyService() 72 + 73 + // Fetch a profile 74 + let profile = try await service.getProfile(for: "alice.bsky.social") 75 + print("\(profile.displayName ?? profile.handle) has \(profile.followersCount ?? 0) followers") 76 + 77 + // Get your timeline 78 + let timeline = try await service.getTimeline(limit: 50) 79 + for item in timeline.feed { 80 + print("\(item.post.author.handle): \(item.post.record.text)") 81 + } 82 + 83 + // Search for users 84 + let results = try await service.searchActors(query: "swift developer", limit: 10) 85 + ``` 86 + 87 + ### Creating Content 88 + 89 + Use `RepoService` for write operations: 90 + 91 + ```swift 92 + let repo = await RepoService() 93 + let myDID = "did:plc:your-did-here" 94 + 95 + // Create a simple post 96 + let post = PostRecord.create(text: "Hello from bskyKit!") 97 + let result = try await repo.createPost(post, repo: myDID) 98 + 99 + // Create a post with rich text (auto-detected) 100 + let richPost = PostRecord.create( 101 + text: "Hey @alice.bsky.social check out https://example.com #swift" 102 + ) 103 + // Mentions, links, and hashtags are automatically detected! 104 + try await repo.createPost(richPost, repo: myDID) 105 + 106 + // Like a post 107 + try await repo.like(uri: postURI, cid: postCID, repo: myDID) 108 + 109 + // Follow someone 110 + try await repo.follow(did: "did:plc:someone", repo: myDID) 111 + ``` 112 + 113 + ## API Reference 114 + 115 + ### BskyService (Read Operations) 116 + 117 + #### Actor Operations 118 + 119 + | Method | Description | 120 + |--------|-------------| 121 + | `getProfile(for:)` | Fetch a user profile by handle or DID | 122 + | `getProfiles(for:)` | Fetch multiple profiles in one request | 123 + | `getPreferences()` | Get authenticated user's preferences | 124 + | `searchActors(query:limit:)` | Search users by name/handle/bio | 125 + | `searchActorsTypeahead(query:limit:)` | Fast search for autocomplete | 126 + 127 + #### Feed Operations 128 + 129 + | Method | Description | 130 + |--------|-------------| 131 + | `getTimeline(limit:cursor:)` | Get home timeline | 132 + | `getAuthorFeed(for:limit:cursor:)` | Get a user's posts | 133 + | `getPostThread(uri:depth:)` | Get post with replies | 134 + | `getPosts(uris:)` | Fetch multiple posts by URI | 135 + | `getFeedGenerators(for:)` | Get custom feed info | 136 + | `getLikes(uri:limit:cursor:)` | Get users who liked a post | 137 + | `getRepostedBy(uri:limit:cursor:)` | Get users who reposted | 138 + 139 + #### Graph Operations 140 + 141 + | Method | Description | 142 + |--------|-------------| 143 + | `getFollows(for:limit:cursor:)` | Get who a user follows | 144 + | `getFollowers(for:limit:cursor:)` | Get a user's followers | 145 + | `getBlocks(limit:cursor:)` | Get your blocked accounts | 146 + | `getMutes(limit:cursor:)` | Get your muted accounts | 147 + 148 + #### Notification Operations 149 + 150 + | Method | Description | 151 + |--------|-------------| 152 + | `listNotifications(limit:cursor:)` | Get notifications | 153 + | `getUnreadCount()` | Get unread notification count | 154 + | `updateSeen(at:)` | Mark notifications as read | 155 + 156 + ### RepoService (Write Operations) 157 + 158 + #### High-Level Methods 159 + 160 + ```swift 161 + // Posts 162 + createPost(_ post: PostRecord, repo: String) -> CreateRecordResponse 163 + 164 + // Interactions 165 + like(uri:cid:repo:) -> CreateRecordResponse 166 + unlike(uri:repo:) 167 + repost(uri:cid:repo:) -> CreateRecordResponse 168 + unrepost(uri:repo:) 169 + 170 + // Social Graph 171 + follow(did:repo:) -> CreateRecordResponse 172 + unfollow(uri:repo:) 173 + block(did:repo:) -> CreateRecordResponse 174 + unblock(uri:repo:) 175 + ``` 176 + 177 + #### Low-Level Record Operations 178 + 179 + ```swift 180 + createRecord(repo:collection:record:rkey:) -> CreateRecordResponse 181 + deleteRecord(repo:collection:rkey:) 182 + getRecord(repo:collection:rkey:) -> GetRecordResponse 183 + listRecords(repo:collection:limit:cursor:) -> ListRecordsResponse 184 + ``` 185 + 186 + ## Rich Text 187 + 188 + bskyKit handles the complexity of AT Protocol rich text facets automatically. 189 + 190 + ### Automatic Detection 191 + 192 + The easiest approach - facets are detected automatically: 193 + 194 + ```swift 195 + let post = PostRecord.create( 196 + text: "Hey @alice.bsky.social! Check https://swift.org #SwiftLang" 197 + ) 198 + // post.facets contains 3 facets: mention, link, and hashtag 199 + ``` 200 + 201 + ### Manual Rich Text Processing 202 + 203 + For more control, use the `RichText` type directly: 204 + 205 + ```swift 206 + let text = "Hello @bob.bsky.social!" 207 + let richText = RichText.detect(in: text) 208 + 209 + for facet in richText.facets { 210 + switch facet.features[0] { 211 + case .mention(let mention): 212 + print("Mentioned: \(mention.handle ?? "unknown")") 213 + // Resolve handle to DID before posting 214 + case .link(let link): 215 + print("Link to: \(link.uri)") 216 + case .tag(let tag): 217 + print("Hashtag: #\(tag.tag)") 218 + } 219 + } 220 + ``` 221 + 222 + ### Byte Index Handling 223 + 224 + AT Protocol uses UTF-8 byte indices, not character indices. bskyKit handles this automatically, but if you need manual conversion: 225 + 226 + ```swift 227 + let text = "Hi 👋 there" // Emoji = 4 bytes 228 + let richText = RichText(text: text) 229 + 230 + // Convert character index to byte index 231 + let charIndex = text.index(text.startIndex, offsetBy: 4) 232 + let byteIndex = richText.byteIndex(from: charIndex) // Returns 7 233 + 234 + // Convert byte index to character index 235 + let charIdx = richText.characterIndex(from: 7) 236 + ``` 237 + 238 + ## Pagination 239 + 240 + All list endpoints support cursor-based pagination: 241 + 242 + ```swift 243 + let service = await BskyService() 244 + 245 + // First page 246 + var timeline = try await service.getTimeline(limit: 50) 247 + displayPosts(timeline.feed) 248 + 249 + // Load more 250 + while let cursor = timeline.cursor { 251 + timeline = try await service.getTimeline(limit: 50, cursor: cursor) 252 + displayPosts(timeline.feed) 253 + } 254 + ``` 255 + 256 + ## Creating Posts with Embeds 257 + 258 + ### Reply to a Post 259 + 260 + ```swift 261 + let replyRef = ReplyRef( 262 + root: PostRef(uri: rootPostURI, cid: rootPostCID), 263 + parent: PostRef(uri: parentPostURI, cid: parentPostCID) 264 + ) 265 + 266 + let post = PostRecord.create( 267 + text: "This is my reply!", 268 + reply: replyRef 269 + ) 270 + try await repo.createPost(post, repo: myDID) 271 + ``` 272 + 273 + ### Quote Post 274 + 275 + ```swift 276 + let post = PostRecord( 277 + text: "Check out this post!", 278 + embed: .record(RecordEmbed(uri: quotedPostURI, cid: quotedPostCID)) 279 + ) 280 + try await repo.createPost(post, repo: myDID) 281 + ``` 282 + 283 + ### External Link Card 284 + 285 + ```swift 286 + let post = PostRecord( 287 + text: "Great article", 288 + embed: .external(ExternalEmbed( 289 + uri: "https://example.com/article", 290 + title: "Article Title", 291 + description: "A brief description of the article" 292 + )) 293 + ) 294 + try await repo.createPost(post, repo: myDID) 295 + ``` 296 + 297 + ## Models 298 + 299 + All models are `Codable`, `Sendable`, and most are `Identifiable` for SwiftUI compatibility. 300 + 301 + ### Key Types 302 + 303 + | Type | Description | 304 + |------|-------------| 305 + | `Profile` | User profile with stats and viewer state | 306 + | `Timeline` | Home timeline with cursor | 307 + | `TimelineItem` | A post in the timeline (may include reply context) | 308 + | `Post` | Full post with author, record, embed, and stats | 309 + | `PostThread` | Thread view with replies | 310 + | `Notification` | Notification with reason and content | 311 + | `Feed` | Custom feed generator info | 312 + | `Follows` / `Followers` | Social graph lists | 313 + 314 + ### Notification Reasons 315 + 316 + ```swift 317 + public enum NotificationReason: String { 318 + case like 319 + case repost 320 + case follow 321 + case mention 322 + case reply 323 + case quote 324 + case starterpackJoined 325 + } 326 + ``` 327 + 328 + ## Thread Safety 329 + 330 + All services use `@APActor` for thread-safe access: 331 + 332 + ```swift 333 + @APActor 334 + func loadTimeline() async throws { 335 + let service = BskyService() // Safe to create on APActor 336 + let timeline = try await service.getTimeline() 337 + // Process timeline... 338 + } 339 + ``` 340 + 341 + ## Error Handling 342 + 343 + Errors are typed via `AtError` from CoreATProtocol: 344 + 345 + ```swift 346 + do { 347 + let profile = try await service.getProfile(for: "nonexistent.handle") 348 + } catch let error as AtError { 349 + switch error { 350 + case .message(let msg): 351 + // API error (e.g., "ProfileNotFound") 352 + print("Error: \(msg.error) - \(msg.message ?? "")") 353 + case .network(let networkError): 354 + // Network/HTTP error 355 + print("Network error: \(networkError)") 356 + } 357 + } 358 + ``` 359 + 360 + ## Testing 361 + 362 + bskyKit uses Swift Testing. Run tests with: 363 + 364 + ```bash 365 + swift test 366 + ``` 367 + 368 + The test suite includes: 369 + - Rich text detection (links, mentions, hashtags) 370 + - Byte index conversion 371 + - Model decoding 372 + 373 + ## Related Packages 374 + 375 + - **[CoreATProtocol](https://tangled.org/@sparrowtek.com/CoreATProtocol)** - Core networking layer (dependency) 376 + 377 + ## License 378 + 379 + This project is licensed under an [MIT license](https://tangled.org/sparrowtek.com/bskyKit/blob/main/LICENSE). 380 + 381 + ## Contributing 382 + 383 + It is always a good idea to discuss before taking on a significant task. That said, I have a strong bias towards enthusiasm. If you are excited about doing something, I'll do my best to get out of your way. 384 + 385 + By participating in this project you agree to abide by the [Contributor Code of Conduct](https://tangled.org/sparrowtek.com/bskyKit/blob/main/CODE_OF_CONDUCT.md).
+185
Sources/bskyKit/BskyAPI.swift
··· 1 + // 2 + // bskyAPI.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 10/11/25. 6 + // 7 + 8 + import Foundation 9 + import CoreATProtocol 10 + 11 + enum BskyAPI { 12 + // Actor endpoints 13 + case getPreferences 14 + case getProfile(did: String) 15 + case getProfiles(dids: [String]) 16 + case searchActors(query: String, limit: Int) 17 + case searchActorsTypeahead(query: String, limit: Int) 18 + 19 + // Feed endpoints 20 + case getFeedGenerators(feeds: [String]) 21 + case getTimeline(limit: Int, cursor: String?) 22 + case getAuthorFeed(did: String, limit: Int, cursor: String?) 23 + case getPostThread(uri: String, depth: Int) 24 + case getPosts(uris: [String]) 25 + case getLikes(uri: String, limit: Int, cursor: String?) 26 + case getRepostedBy(uri: String, limit: Int, cursor: String?) 27 + 28 + // Graph endpoints 29 + case getFollows(did: String, limit: Int, cursor: String?) 30 + case getFollowers(did: String, limit: Int, cursor: String?) 31 + case getBlocks(limit: Int, cursor: String?) 32 + case getMutes(limit: Int, cursor: String?) 33 + 34 + // Notification endpoints 35 + case listNotifications(limit: Int, cursor: String?) 36 + case getUnreadCount 37 + case updateSeen(seenAt: Date) 38 + } 39 + 40 + extension BskyAPI: EndpointType { 41 + public var baseURL: URL { 42 + get async { 43 + guard let host = await APEnvironment.current.host else { fatalError("Host not set.") } 44 + guard let url = URL(string: host) else { fatalError("BskyAPI baseURL not configured.") } 45 + return url 46 + } 47 + } 48 + 49 + var path: String { 50 + switch self { 51 + // Actor 52 + case .getPreferences: "/xrpc/app.bsky.actor.getPreferences" 53 + case .getProfile: "/xrpc/app.bsky.actor.getProfile" 54 + case .getProfiles: "/xrpc/app.bsky.actor.getProfiles" 55 + case .searchActors: "/xrpc/app.bsky.actor.searchActors" 56 + case .searchActorsTypeahead: "/xrpc/app.bsky.actor.searchActorsTypeahead" 57 + // Feed 58 + case .getFeedGenerators: "/xrpc/app.bsky.feed.getFeedGenerators" 59 + case .getTimeline: "/xrpc/app.bsky.feed.getTimeline" 60 + case .getAuthorFeed: "/xrpc/app.bsky.feed.getAuthorFeed" 61 + case .getPostThread: "/xrpc/app.bsky.feed.getPostThread" 62 + case .getPosts: "/xrpc/app.bsky.feed.getPosts" 63 + case .getLikes: "/xrpc/app.bsky.feed.getLikes" 64 + case .getRepostedBy: "/xrpc/app.bsky.feed.getRepostedBy" 65 + // Graph 66 + case .getFollows: "/xrpc/app.bsky.graph.getFollows" 67 + case .getFollowers: "/xrpc/app.bsky.graph.getFollowers" 68 + case .getBlocks: "/xrpc/app.bsky.graph.getBlocks" 69 + case .getMutes: "/xrpc/app.bsky.graph.getMutes" 70 + // Notifications 71 + case .listNotifications: "/xrpc/app.bsky.notification.listNotifications" 72 + case .getUnreadCount: "/xrpc/app.bsky.notification.getUnreadCount" 73 + case .updateSeen: "/xrpc/app.bsky.notification.updateSeen" 74 + } 75 + } 76 + 77 + var httpMethod: HTTPMethod { 78 + switch self { 79 + case .getPreferences, .getProfile, .getProfiles, .searchActors, .searchActorsTypeahead, 80 + .getFeedGenerators, .getTimeline, .getAuthorFeed, .getPostThread, .getPosts, .getLikes, .getRepostedBy, 81 + .getFollows, .getFollowers, .getBlocks, .getMutes, 82 + .listNotifications, .getUnreadCount: 83 + return .get 84 + case .updateSeen: 85 + return .post 86 + } 87 + } 88 + 89 + var task: HTTPTask { 90 + switch self { 91 + // Actor endpoints 92 + case .getPreferences, .getUnreadCount: 93 + return .request 94 + 95 + case .getProfile(let did): 96 + return .requestParameters(encoding: .urlEncoding(parameters: ["actor": did])) 97 + 98 + case .getProfiles(let dids): 99 + return .requestParameters(encoding: .urlEncoding(parameters: ["actors": dids])) 100 + 101 + case .searchActors(let query, let limit): 102 + return .requestParameters(encoding: .urlEncoding(parameters: [ 103 + "q": query, 104 + "limit": limit 105 + ])) 106 + 107 + case .searchActorsTypeahead(let query, let limit): 108 + return .requestParameters(encoding: .urlEncoding(parameters: [ 109 + "q": query, 110 + "limit": limit 111 + ])) 112 + 113 + // Feed endpoints 114 + case .getFeedGenerators(let feeds): 115 + return .requestParameters(encoding: .urlEncoding(parameters: ["feeds": feeds])) 116 + 117 + case .getTimeline(let limit, let cursor): 118 + var params: Parameters = ["limit": limit] 119 + if let cursor { params["cursor"] = cursor } 120 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 121 + 122 + case .getAuthorFeed(let did, let limit, let cursor): 123 + var params: Parameters = ["actor": did, "limit": limit] 124 + if let cursor { params["cursor"] = cursor } 125 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 126 + 127 + case .getPostThread(let uri, let depth): 128 + return .requestParameters(encoding: .urlEncoding(parameters: [ 129 + "uri": uri, 130 + "depth": depth 131 + ])) 132 + 133 + case .getPosts(let uris): 134 + return .requestParameters(encoding: .urlEncoding(parameters: ["uris": uris])) 135 + 136 + case .getLikes(let uri, let limit, let cursor): 137 + var params: Parameters = ["uri": uri, "limit": limit] 138 + if let cursor { params["cursor"] = cursor } 139 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 140 + 141 + case .getRepostedBy(let uri, let limit, let cursor): 142 + var params: Parameters = ["uri": uri, "limit": limit] 143 + if let cursor { params["cursor"] = cursor } 144 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 145 + 146 + // Graph endpoints 147 + case .getFollows(let did, let limit, let cursor): 148 + var params: Parameters = ["actor": did, "limit": limit] 149 + if let cursor { params["cursor"] = cursor } 150 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 151 + 152 + case .getFollowers(let did, let limit, let cursor): 153 + var params: Parameters = ["actor": did, "limit": limit] 154 + if let cursor { params["cursor"] = cursor } 155 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 156 + 157 + case .getBlocks(let limit, let cursor): 158 + var params: Parameters = ["limit": limit] 159 + if let cursor { params["cursor"] = cursor } 160 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 161 + 162 + case .getMutes(let limit, let cursor): 163 + var params: Parameters = ["limit": limit] 164 + if let cursor { params["cursor"] = cursor } 165 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 166 + 167 + // Notification endpoints 168 + case .listNotifications(let limit, let cursor): 169 + var params: Parameters = ["limit": limit] 170 + if let cursor { params["cursor"] = cursor } 171 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 172 + 173 + case .updateSeen(let seenAt): 174 + let formatter = ISO8601DateFormatter() 175 + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 176 + return .requestParameters(encoding: .jsonEncoding(parameters: [ 177 + "seenAt": formatter.string(from: seenAt) 178 + ])) 179 + } 180 + } 181 + 182 + var headers: HTTPHeaders? { 183 + nil 184 + } 185 + }
+261
Sources/bskyKit/BskyService.swift
··· 1 + // 2 + // bskyService.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 10/11/25. 6 + // 7 + 8 + import Foundation 9 + import CoreATProtocol 10 + 11 + /// The main service for reading Bluesky social data. 12 + /// 13 + /// `BskyService` provides methods for fetching profiles, timelines, feeds, 14 + /// social graph data, and notifications from the Bluesky network. 15 + /// 16 + /// ## Overview 17 + /// 18 + /// Use `BskyService` for all read operations. For write operations (creating posts, 19 + /// likes, follows, etc.), use ``RepoService`` instead. 20 + /// 21 + /// ## Example 22 + /// 23 + /// ```swift 24 + /// // Configure the environment first 25 + /// await setup(hostURL: "https://bsky.social", accessJWT: token, refreshJWT: nil) 26 + /// 27 + /// // Create service and fetch data 28 + /// let service = await BskyService() 29 + /// let profile = try await service.getProfile(for: "alice.bsky.social") 30 + /// let timeline = try await service.getTimeline(limit: 20) 31 + /// ``` 32 + /// 33 + /// ## Topics 34 + /// 35 + /// ### Actor Operations 36 + /// - ``getPreferences()`` 37 + /// - ``getProfile(for:)`` 38 + /// - ``getProfiles(for:)`` 39 + /// - ``searchActors(query:limit:)`` 40 + /// - ``searchActorsTypeahead(query:limit:)`` 41 + /// 42 + /// ### Feed Operations 43 + /// - ``getTimeline(limit:cursor:)`` 44 + /// - ``getAuthorFeed(for:limit:cursor:)`` 45 + /// - ``getPostThread(uri:depth:)`` 46 + /// - ``getPosts(uris:)`` 47 + /// - ``getFeedGenerators(for:)`` 48 + /// - ``getLikes(uri:limit:cursor:)`` 49 + /// - ``getRepostedBy(uri:limit:cursor:)`` 50 + /// 51 + /// ### Graph Operations 52 + /// - ``getFollows(for:limit:cursor:)`` 53 + /// - ``getFollowers(for:limit:cursor:)`` 54 + /// - ``getBlocks(limit:cursor:)`` 55 + /// - ``getMutes(limit:cursor:)`` 56 + /// 57 + /// ### Notification Operations 58 + /// - ``listNotifications(limit:cursor:)`` 59 + /// - ``getUnreadCount()`` 60 + /// - ``updateSeen(at:)`` 61 + @APActor 62 + public struct BskyService: Sendable { 63 + private let router: NetworkRouter<BskyAPI> = { 64 + let router = NetworkRouter<BskyAPI>(decoder: .atDecoder) 65 + router.delegate = APEnvironment.current.routerDelegate 66 + return router 67 + }() 68 + 69 + /// Creates a new BskyService instance. 70 + /// 71 + /// Before using the service, ensure you've configured the environment with 72 + /// `setup(hostURL:accessJWT:refreshJWT:)`. 73 + public init() {} 74 + 75 + // MARK: - Actor 76 + 77 + /// Fetches the authenticated user's preferences. 78 + /// - Returns: The user's saved preferences including pinned feeds. 79 + /// - Throws: An error if the request fails or the user is not authenticated. 80 + public func getPreferences() async throws -> Preferences { 81 + try await router.execute(.getPreferences) 82 + } 83 + 84 + /// Fetches a user profile by handle or DID. 85 + /// - Parameter did: The user's handle (e.g., "alice.bsky.social") or DID. 86 + /// - Returns: The user's profile. 87 + /// - Throws: An error if the profile is not found or the request fails. 88 + public func getProfile(for did: String) async throws -> Profile { 89 + try await router.execute(.getProfile(did: did)) 90 + } 91 + 92 + /// Fetches multiple user profiles in a single request. 93 + /// - Parameter dids: Array of handles or DIDs to fetch. 94 + /// - Returns: The profiles for the requested users. 95 + /// - Throws: An error if the request fails. 96 + public func getProfiles(for dids: [String]) async throws -> Profiles { 97 + try await router.execute(.getProfiles(dids: dids)) 98 + } 99 + 100 + /// Searches for users matching a query. 101 + /// - Parameters: 102 + /// - query: Search query string (searches handle, display name, and bio). 103 + /// - limit: Maximum number of results to return (default: 25, max: 100). 104 + /// - Returns: Matching user profiles with optional cursor for pagination. 105 + /// - Throws: An error if the request fails. 106 + public func searchActors(query: String, limit: Int = 25) async throws -> SearchActorsResult { 107 + try await router.execute(.searchActors(query: query, limit: limit)) 108 + } 109 + 110 + /// Fast search for autocomplete functionality. 111 + /// - Parameters: 112 + /// - query: Search prefix for typeahead matching. 113 + /// - limit: Maximum number of results (default: 10). 114 + /// - Returns: Matching profiles optimized for autocomplete. 115 + /// - Throws: An error if the request fails. 116 + public func searchActorsTypeahead(query: String, limit: Int = 10) async throws -> SearchActorsTypeaheadResult { 117 + try await router.execute(.searchActorsTypeahead(query: query, limit: limit)) 118 + } 119 + 120 + // MARK: - Feed 121 + 122 + /// Fetches information about custom feed generators. 123 + /// - Parameter feeds: Array of feed generator AT-URIs. 124 + /// - Returns: Details about the requested feed generators. 125 + /// - Throws: An error if the request fails. 126 + public func getFeedGenerators(for feeds: [String]) async throws -> Feeds { 127 + try await router.execute(.getFeedGenerators(feeds: feeds)) 128 + } 129 + 130 + /// Fetches the authenticated user's home timeline. 131 + /// - Parameters: 132 + /// - limit: Maximum number of posts to return (default: 50, max: 100). 133 + /// - cursor: Pagination cursor from a previous response. 134 + /// - Returns: Timeline posts with cursor for pagination. 135 + /// - Throws: An error if the user is not authenticated or the request fails. 136 + public func getTimeline(limit: Int = 50, cursor: String? = nil) async throws -> Timeline { 137 + try await router.execute(.getTimeline(limit: limit, cursor: cursor)) 138 + } 139 + 140 + /// Fetches posts from a specific user's feed. 141 + /// - Parameters: 142 + /// - did: The user's DID or handle. 143 + /// - limit: Maximum number of posts to return (default: 50). 144 + /// - cursor: Pagination cursor from a previous response. 145 + /// - Returns: The user's posts with cursor for pagination. 146 + /// - Throws: An error if the request fails. 147 + public func getAuthorFeed(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed { 148 + try await router.execute(.getAuthorFeed(did: did, limit: limit, cursor: cursor)) 149 + } 150 + 151 + /// Fetches a post and its reply thread. 152 + /// - Parameters: 153 + /// - uri: The AT-URI of the post. 154 + /// - depth: How many levels of replies to fetch (default: 6). 155 + /// - Returns: The post thread with nested replies. 156 + /// - Throws: An error if the post is not found or the request fails. 157 + public func getPostThread(uri: String, depth: Int = 6) async throws -> PostThreadResponse { 158 + try await router.execute(.getPostThread(uri: uri, depth: depth)) 159 + } 160 + 161 + /// Fetches multiple posts by URI in a single request. 162 + /// - Parameter uris: Array of post AT-URIs to fetch. 163 + /// - Returns: The requested posts. 164 + /// - Throws: An error if the request fails. 165 + public func getPosts(uris: [String]) async throws -> Posts { 166 + try await router.execute(.getPosts(uris: uris)) 167 + } 168 + 169 + /// Fetches users who liked a specific post. 170 + /// - Parameters: 171 + /// - uri: The AT-URI of the post. 172 + /// - limit: Maximum number of likes to return (default: 50). 173 + /// - cursor: Pagination cursor from a previous response. 174 + /// - Returns: Users who liked the post with cursor for pagination. 175 + /// - Throws: An error if the request fails. 176 + public func getLikes(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> Likes { 177 + try await router.execute(.getLikes(uri: uri, limit: limit, cursor: cursor)) 178 + } 179 + 180 + /// Fetches users who reposted a specific post. 181 + /// - Parameters: 182 + /// - uri: The AT-URI of the post. 183 + /// - limit: Maximum number of reposts to return (default: 50). 184 + /// - cursor: Pagination cursor from a previous response. 185 + /// - Returns: Users who reposted with cursor for pagination. 186 + /// - Throws: An error if the request fails. 187 + public func getRepostedBy(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> RepostedBy { 188 + try await router.execute(.getRepostedBy(uri: uri, limit: limit, cursor: cursor)) 189 + } 190 + 191 + // MARK: - Graph 192 + 193 + /// Fetches the list of users that a specific user follows. 194 + /// - Parameters: 195 + /// - did: The DID or handle of the user. 196 + /// - limit: Maximum number of follows to return (default: 50). 197 + /// - cursor: Pagination cursor from a previous response. 198 + /// - Returns: Users being followed with cursor for pagination. 199 + /// - Throws: An error if the request fails. 200 + public func getFollows(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> Follows { 201 + try await router.execute(.getFollows(did: did, limit: limit, cursor: cursor)) 202 + } 203 + 204 + /// Fetches the list of users following a specific user. 205 + /// - Parameters: 206 + /// - did: The DID or handle of the user. 207 + /// - limit: Maximum number of followers to return (default: 50). 208 + /// - cursor: Pagination cursor from a previous response. 209 + /// - Returns: Followers with cursor for pagination. 210 + /// - Throws: An error if the request fails. 211 + public func getFollowers(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> Followers { 212 + try await router.execute(.getFollowers(did: did, limit: limit, cursor: cursor)) 213 + } 214 + 215 + /// Fetches the authenticated user's blocked accounts. 216 + /// - Parameters: 217 + /// - limit: Maximum number of blocks to return (default: 50). 218 + /// - cursor: Pagination cursor from a previous response. 219 + /// - Returns: Blocked profiles with cursor for pagination. 220 + /// - Throws: An error if not authenticated or the request fails. 221 + public func getBlocks(limit: Int = 50, cursor: String? = nil) async throws -> Blocks { 222 + try await router.execute(.getBlocks(limit: limit, cursor: cursor)) 223 + } 224 + 225 + /// Fetches the authenticated user's muted accounts. 226 + /// - Parameters: 227 + /// - limit: Maximum number of mutes to return (default: 50). 228 + /// - cursor: Pagination cursor from a previous response. 229 + /// - Returns: Muted profiles with cursor for pagination. 230 + /// - Throws: An error if not authenticated or the request fails. 231 + public func getMutes(limit: Int = 50, cursor: String? = nil) async throws -> Mutes { 232 + try await router.execute(.getMutes(limit: limit, cursor: cursor)) 233 + } 234 + 235 + // MARK: - Notifications 236 + 237 + /// Fetches the authenticated user's notifications. 238 + /// - Parameters: 239 + /// - limit: Maximum number of notifications to return (default: 50). 240 + /// - cursor: Pagination cursor from a previous response. 241 + /// - Returns: Notifications with cursor for pagination. 242 + /// - Throws: An error if not authenticated or the request fails. 243 + public func listNotifications(limit: Int = 50, cursor: String? = nil) async throws -> NotificationsResponse { 244 + try await router.execute(.listNotifications(limit: limit, cursor: cursor)) 245 + } 246 + 247 + /// Fetches the count of unread notifications. 248 + /// - Returns: The number of unread notifications. 249 + /// - Throws: An error if not authenticated or the request fails. 250 + public func getUnreadCount() async throws -> UnreadCount { 251 + try await router.execute(.getUnreadCount) 252 + } 253 + 254 + /// Marks notifications as seen up to the specified time. 255 + /// - Parameter date: The timestamp to mark as seen (default: now). 256 + /// - Throws: An error if not authenticated or the request fails. 257 + public func updateSeen(at date: Date = Date()) async throws { 258 + let _: EmptyResponse = try await router.execute(.updateSeen(seenAt: date)) 259 + } 260 + } 261 +
+180
Sources/bskyKit/Documentation.docc/GettingStarted.md
··· 1 + # Getting Started with bskyKit 2 + 3 + Learn how to integrate bskyKit into your Swift project and make your first API calls. 4 + 5 + ## Overview 6 + 7 + This guide walks you through setting up bskyKit, authenticating with Bluesky, and performing common operations like fetching profiles and reading timelines. 8 + 9 + ## Adding bskyKit to Your Project 10 + 11 + Add bskyKit as a dependency in your `Package.swift`: 12 + 13 + ```swift 14 + dependencies: [ 15 + .package(path: "../bskyKit"), 16 + // Or from a remote URL: 17 + // .package(url: "https://your-repo/bskyKit", branch: "main"), 18 + ] 19 + ``` 20 + 21 + Then add it to your target: 22 + 23 + ```swift 24 + .target( 25 + name: "YourApp", 26 + dependencies: ["bskyKit"] 27 + ) 28 + ``` 29 + 30 + ## Configuration 31 + 32 + Before making API calls, configure the CoreATProtocol environment with your host and authentication tokens: 33 + 34 + ```swift 35 + import bskyKit 36 + import CoreATProtocol 37 + 38 + @main 39 + struct MyApp { 40 + static func main() async throws { 41 + // Configure the environment 42 + await setup( 43 + hostURL: "https://bsky.social", 44 + accessJWT: "your-access-token", 45 + refreshJWT: "your-refresh-token" 46 + ) 47 + 48 + // Now you can use bskyKit services 49 + let service = await BskyService() 50 + // ... 51 + } 52 + } 53 + ``` 54 + 55 + > Important: Never hardcode tokens in your source code. Use secure storage like Keychain for production apps. 56 + 57 + ## Getting Authentication Tokens 58 + 59 + To obtain authentication tokens, you need to authenticate with the Bluesky PDS. Here's a simplified example using the `com.atproto.server.createSession` endpoint: 60 + 61 + ```swift 62 + // This is a simplified example - use proper OAuth flow in production 63 + let credentials = [ 64 + "identifier": "your.handle.bsky.social", 65 + "password": "your-app-password" 66 + ] 67 + 68 + // POST to https://bsky.social/xrpc/com.atproto.server.createSession 69 + // Response includes accessJwt and refreshJwt 70 + ``` 71 + 72 + For production apps, implement proper OAuth 2.0 authentication with DPoP. 73 + 74 + ## Fetching Your First Profile 75 + 76 + Once configured, you can fetch user profiles: 77 + 78 + ```swift 79 + import bskyKit 80 + import CoreATProtocol 81 + 82 + func fetchProfile() async throws { 83 + let service = await BskyService() 84 + 85 + // Fetch by handle or DID 86 + let profile = try await service.getProfile(for: "alice.bsky.social") 87 + 88 + print("Handle: @\(profile.handle)") 89 + print("Display Name: \(profile.displayName ?? "N/A")") 90 + print("Followers: \(profile.followersCount ?? 0)") 91 + print("Following: \(profile.followsCount ?? 0)") 92 + print("Posts: \(profile.postsCount ?? 0)") 93 + 94 + if let bio = profile.description { 95 + print("Bio: \(bio)") 96 + } 97 + } 98 + ``` 99 + 100 + ## Reading the Timeline 101 + 102 + Fetch the authenticated user's home timeline: 103 + 104 + ```swift 105 + func readTimeline() async throws { 106 + let service = await BskyService() 107 + 108 + // Fetch 20 posts 109 + let timeline = try await service.getTimeline(limit: 20) 110 + 111 + for item in timeline.feed { 112 + let post = item.post 113 + print("@\(post.author.handle): \(post.record.text)") 114 + print(" Likes: \(post.likeCount) | Reposts: \(post.repostCount)") 115 + print("") 116 + } 117 + 118 + // Use cursor for pagination 119 + if !timeline.cursor.isEmpty { 120 + let nextPage = try await service.getTimeline(limit: 20, cursor: timeline.cursor) 121 + // Process next page... 122 + } 123 + } 124 + ``` 125 + 126 + ## Searching for Users 127 + 128 + Search for users by name or handle: 129 + 130 + ```swift 131 + func searchUsers(query: String) async throws { 132 + let service = await BskyService() 133 + 134 + let results = try await service.searchActors(query: query, limit: 10) 135 + 136 + for actor in results.actors { 137 + print("@\(actor.handle)") 138 + if let name = actor.displayName { 139 + print(" Name: \(name)") 140 + } 141 + if let bio = actor.description { 142 + print(" Bio: \(String(bio.prefix(100)))...") 143 + } 144 + } 145 + } 146 + ``` 147 + 148 + ## Error Handling 149 + 150 + bskyKit uses Swift's native error handling. Common errors include: 151 + 152 + ```swift 153 + import CoreATProtocol 154 + 155 + func fetchWithErrorHandling() async { 156 + let service = await BskyService() 157 + 158 + do { 159 + let profile = try await service.getProfile(for: "nonexistent.user") 160 + } catch let error as AtError { 161 + switch error { 162 + case .message(let msg): 163 + print("API Error: \(msg.error) - \(msg.message ?? "")") 164 + case .network(let networkError): 165 + print("Network Error: \(networkError)") 166 + } 167 + } catch { 168 + print("Unexpected error: \(error)") 169 + } 170 + } 171 + ``` 172 + 173 + ## Next Steps 174 + 175 + Now that you've made your first API calls, explore these topics: 176 + 177 + - <doc:WorkingWithProfiles> - Deep dive into profile operations 178 + - <doc:TimelineAndFeeds> - Working with timelines and custom feeds 179 + - <doc:RichTextGuide> - Creating posts with mentions, links, and hashtags 180 + - <doc:SocialActions> - Liking, reposting, and following
+254
Sources/bskyKit/Documentation.docc/Notifications.md
··· 1 + # Notifications 2 + 3 + List, read, and manage notifications. 4 + 5 + ## Overview 6 + 7 + Bluesky notifications inform users about likes, reposts, follows, mentions, replies, and quotes. bskyKit provides APIs to list notifications, check unread counts, and mark notifications as read. 8 + 9 + ## Listing Notifications 10 + 11 + Use ``BskyService/listNotifications(limit:cursor:)`` to fetch notifications: 12 + 13 + ```swift 14 + let service = await BskyService() 15 + 16 + let response = try await service.listNotifications(limit: 50) 17 + 18 + for notification in response.notifications { 19 + print("[\(notification.reason.rawValue)] @\(notification.author.handle)") 20 + 21 + switch notification.reason { 22 + case .like: 23 + print(" liked your post") 24 + case .repost: 25 + print(" reposted your post") 26 + case .follow: 27 + print(" followed you") 28 + case .mention: 29 + print(" mentioned you") 30 + case .reply: 31 + print(" replied to you") 32 + if let text = notification.record?.text { 33 + print(" \"\(text)\"") 34 + } 35 + case .quote: 36 + print(" quoted your post") 37 + case .starterpackJoined: 38 + print(" joined via your starter pack") 39 + } 40 + 41 + print(" Read: \(notification.isRead)") 42 + print("") 43 + } 44 + ``` 45 + 46 + ## Pagination 47 + 48 + Paginate through notifications with cursors: 49 + 50 + ```swift 51 + var allNotifications: [Notification] = [] 52 + var cursor: String? = nil 53 + 54 + repeat { 55 + let response = try await service.listNotifications( 56 + limit: 100, 57 + cursor: cursor 58 + ) 59 + allNotifications.append(contentsOf: response.notifications) 60 + cursor = response.cursor 61 + } while cursor != nil 62 + 63 + print("Total notifications: \(allNotifications.count)") 64 + ``` 65 + 66 + ## Unread Count 67 + 68 + Check the number of unread notifications: 69 + 70 + ```swift 71 + let unread = try await service.getUnreadCount() 72 + print("You have \(unread.count) unread notifications") 73 + ``` 74 + 75 + ## Mark as Read 76 + 77 + Mark all notifications as seen up to the current time: 78 + 79 + ```swift 80 + try await service.updateSeen() 81 + print("Notifications marked as read") 82 + 83 + // Or mark as seen at a specific time 84 + try await service.updateSeen(at: Date()) 85 + ``` 86 + 87 + ## Filtering Notifications 88 + 89 + Filter notifications by type: 90 + 91 + ```swift 92 + let response = try await service.listNotifications(limit: 100) 93 + 94 + // Only likes 95 + let likes = response.notifications.filter { $0.reason == .like } 96 + print("Likes: \(likes.count)") 97 + 98 + // Only follows 99 + let follows = response.notifications.filter { $0.reason == .follow } 100 + print("New followers: \(follows.count)") 101 + 102 + // Only interactions (likes, reposts, quotes) 103 + let interactions = response.notifications.filter { 104 + [.like, .repost, .quote].contains($0.reason) 105 + } 106 + print("Interactions: \(interactions.count)") 107 + 108 + // Only conversations (mentions, replies) 109 + let conversations = response.notifications.filter { 110 + [.mention, .reply].contains($0.reason) 111 + } 112 + print("Conversations: \(conversations.count)") 113 + ``` 114 + 115 + ## Notification Reasons 116 + 117 + The ``NotificationReason`` enum defines all notification types: 118 + 119 + ```swift 120 + public enum NotificationReason: String, Codable, Sendable { 121 + case like // Someone liked your post 122 + case repost // Someone reposted your post 123 + case follow // Someone followed you 124 + case mention // Someone mentioned you in a post 125 + case reply // Someone replied to your post 126 + case quote // Someone quoted your post 127 + case starterpackJoined = "starterpack-joined" // Someone joined via your starter pack 128 + } 129 + ``` 130 + 131 + ## Model Reference 132 + 133 + ### NotificationsResponse 134 + 135 + ```swift 136 + public struct NotificationsResponse: Codable, Sendable { 137 + public let notifications: [Notification] 138 + public let cursor: String? 139 + public let seenAt: Date? 140 + } 141 + ``` 142 + 143 + ### Notification 144 + 145 + ```swift 146 + public struct Notification: Codable, Sendable, Identifiable { 147 + public let uri: String 148 + public let cid: String 149 + public let author: NotificationAuthor 150 + public let reason: NotificationReason 151 + public let reasonSubject: String? // URI of the post that was interacted with 152 + public let record: NotificationRecord? // Content for mentions/replies 153 + public let isRead: Bool 154 + public let indexedAt: Date 155 + 156 + public var id: String { uri } 157 + } 158 + ``` 159 + 160 + ### NotificationAuthor 161 + 162 + ```swift 163 + public struct NotificationAuthor: Codable, Sendable, Identifiable { 164 + public let did: String 165 + public let handle: String 166 + public let displayName: String? 167 + public let avatar: String? 168 + public let viewer: Viewer? 169 + public let labels: [AuthorLabels]? 170 + 171 + public var id: String { did } 172 + } 173 + ``` 174 + 175 + ### NotificationRecord 176 + 177 + For mentions and replies, contains the post content: 178 + 179 + ```swift 180 + public struct NotificationRecord: Codable, Sendable { 181 + public let type: String? 182 + public let text: String? 183 + public let createdAt: Date? 184 + } 185 + ``` 186 + 187 + ### UnreadCount 188 + 189 + ```swift 190 + public struct UnreadCount: Codable, Sendable { 191 + public let count: Int 192 + } 193 + ``` 194 + 195 + ## Common Patterns 196 + 197 + ### Polling for New Notifications 198 + 199 + ```swift 200 + func checkForNewNotifications() async { 201 + let unread = try? await service.getUnreadCount() 202 + if let count = unread?.count, count > 0 { 203 + print("You have \(count) new notifications!") 204 + 205 + // Optionally fetch and display them 206 + let notifications = try? await service.listNotifications(limit: count) 207 + // Process new notifications... 208 + } 209 + } 210 + ``` 211 + 212 + ### Building a Notification Badge 213 + 214 + ```swift 215 + @Observable 216 + class NotificationManager { 217 + var unreadCount: Int = 0 218 + 219 + func refresh() async { 220 + if let count = try? await service.getUnreadCount() { 221 + unreadCount = count.count 222 + } 223 + } 224 + 225 + func markAllRead() async { 226 + try? await service.updateSeen() 227 + unreadCount = 0 228 + } 229 + } 230 + ``` 231 + 232 + ### Grouping Notifications 233 + 234 + ```swift 235 + func groupNotifications(_ notifications: [Notification]) -> [String: [Notification]] { 236 + Dictionary(grouping: notifications) { notification in 237 + notification.reason.rawValue 238 + } 239 + } 240 + 241 + let grouped = groupNotifications(response.notifications) 242 + for (reason, items) in grouped { 243 + print("\(reason): \(items.count)") 244 + } 245 + ``` 246 + 247 + ## See Also 248 + 249 + - ``BskyService`` 250 + - ``NotificationsResponse`` 251 + - ``Notification`` 252 + - ``NotificationReason`` 253 + - ``NotificationAuthor`` 254 + - ``UnreadCount``
+221
Sources/bskyKit/Documentation.docc/RichTextGuide.md
··· 1 + # Rich Text and Facets 2 + 3 + Create posts with clickable mentions, links, and hashtags. 4 + 5 + ## Overview 6 + 7 + Bluesky uses a "facets" system to mark up rich text. Unlike HTML or Markdown, facets use byte indices to identify spans of text that should be rendered as mentions, links, or hashtags. bskyKit's ``RichText`` struct handles this complexity automatically. 8 + 9 + ## Understanding Facets 10 + 11 + Facets are annotations that mark segments of text with special meaning: 12 + 13 + - **Mentions**: `@handle` - Links to a user profile 14 + - **Links**: `https://...` - Clickable URLs 15 + - **Tags**: `#hashtag` - Searchable hashtags 16 + 17 + Each facet specifies: 18 + - `byteStart`: Starting byte position in UTF-8 encoded text 19 + - `byteEnd`: Ending byte position 20 + - `features`: Array of feature types (link, mention, or tag) 21 + 22 + > Important: Facets use **byte indices**, not character indices. This matters for text containing emoji or non-ASCII characters. 23 + 24 + ## Auto-Detecting Facets 25 + 26 + The easiest way to create rich text is with automatic detection: 27 + 28 + ```swift 29 + let text = "Hey @alice.bsky.social check out https://example.com #atproto" 30 + 31 + let richText = RichText.detect(in: text) 32 + 33 + print("Text: \(richText.text)") 34 + print("Facets found: \(richText.facets.count)") 35 + 36 + for facet in richText.facets { 37 + print(" Bytes \(facet.index.byteStart)-\(facet.index.byteEnd)") 38 + for feature in facet.features { 39 + switch feature { 40 + case .mention(let mention): 41 + print(" Mention: @\(mention.handle ?? "")") 42 + case .link(let link): 43 + print(" Link: \(link.uri)") 44 + case .tag(let tag): 45 + print(" Tag: #\(tag.tag)") 46 + } 47 + } 48 + } 49 + ``` 50 + 51 + Output: 52 + ``` 53 + Text: Hey @alice.bsky.social check out https://example.com #atproto 54 + Facets found: 3 55 + Bytes 4-23 56 + Mention: @alice.bsky.social 57 + Bytes 35-54 58 + Link: https://example.com 59 + Bytes 55-63 60 + Tag: #atproto 61 + ``` 62 + 63 + ## Creating Posts with Rich Text 64 + 65 + Use ``PostRecord/create(text:reply:embed:langs:)`` to create posts with auto-detected facets: 66 + 67 + ```swift 68 + let repoService = await RepoService() 69 + 70 + // Create post with auto-detected facets 71 + let post = PostRecord.create( 72 + text: "Hello @friend.bsky.social! Check out https://swift.org #SwiftLang" 73 + ) 74 + 75 + let response = try await repoService.createPost(post, repo: myDID) 76 + print("Created post: \(response.uri)") 77 + ``` 78 + 79 + ## Manual Facet Creation 80 + 81 + For precise control, create facets manually: 82 + 83 + ```swift 84 + let text = "Visit my website" 85 + 86 + let facet = RichTextFacet( 87 + index: RichTextFacetIndex(byteStart: 6, byteEnd: 16), 88 + features: [.link(RichTextLink(uri: "https://example.com"))] 89 + ) 90 + 91 + let richText = RichText(text: text, facets: [facet]) 92 + 93 + let post = PostRecord( 94 + text: richText.text, 95 + facets: richText.facets 96 + ) 97 + ``` 98 + 99 + ## Byte Index Conversion 100 + 101 + When working with user-selected ranges, convert between character and byte indices: 102 + 103 + ```swift 104 + let text = "Hello 👋 World" 105 + let richText = RichText(text: text) 106 + 107 + // Character index to byte index 108 + let charIndex = text.index(text.startIndex, offsetBy: 8) // 'W' in "World" 109 + let byteIndex = richText.byteIndex(from: charIndex) 110 + print("Byte index: \(byteIndex)") // 11 (emoji takes 4 bytes) 111 + 112 + // Byte index to character index 113 + if let charIdx = richText.characterIndex(from: 11) { 114 + print("Character: \(text[charIdx])") // W 115 + } 116 + ``` 117 + 118 + ## Facet Types Reference 119 + 120 + ### RichTextFacet 121 + 122 + ```swift 123 + public struct RichTextFacet: Codable, Sendable { 124 + public let index: RichTextFacetIndex 125 + public let features: [RichTextFeature] 126 + } 127 + ``` 128 + 129 + ### RichTextFacetIndex 130 + 131 + ```swift 132 + public struct RichTextFacetIndex: Codable, Sendable { 133 + public let byteStart: Int 134 + public let byteEnd: Int 135 + } 136 + ``` 137 + 138 + ### RichTextFeature 139 + 140 + ```swift 141 + public enum RichTextFeature: Codable, Sendable { 142 + case link(RichTextLink) 143 + case mention(RichTextMention) 144 + case tag(RichTextTag) 145 + } 146 + ``` 147 + 148 + ### Feature Types 149 + 150 + ```swift 151 + public struct RichTextLink: Codable, Sendable { 152 + public let uri: String 153 + } 154 + 155 + public struct RichTextMention: Codable, Sendable { 156 + public let handle: String? // Before resolution 157 + public let did: String? // After resolution 158 + } 159 + 160 + public struct RichTextTag: Codable, Sendable { 161 + public let tag: String 162 + } 163 + ``` 164 + 165 + ## Detection Rules 166 + 167 + ### Mentions 168 + - Must start with `@` 169 + - Can contain letters, numbers, dots, hyphens, underscores 170 + - Examples: `@alice`, `@bob.bsky.social`, `@did:plc:abc123` 171 + 172 + ### Links 173 + - Detected using NSDataDetector 174 + - Must be valid URLs 175 + - Examples: `https://example.com`, `http://localhost:8080` 176 + 177 + ### Hashtags 178 + - Must start with `#` 179 + - Cannot start with a number 180 + - Can contain letters, numbers, underscores 181 + - Examples: `#Swift`, `#iOS_dev`, `#2024goals` (not detected - starts with number) 182 + 183 + ## Converting for API 184 + 185 + When creating records, convert facets to the API format: 186 + 187 + ```swift 188 + let richText = RichText.detect(in: text) 189 + let apiFacets = richText.toAPIFacets() 190 + // Returns [[String: Any]] suitable for JSON encoding 191 + ``` 192 + 193 + ## Working with Emoji and Unicode 194 + 195 + Emoji and non-ASCII characters require special handling because they occupy multiple bytes in UTF-8: 196 + 197 + ```swift 198 + let text = "Love this! 🎉" 199 + let richText = RichText.detect(in: text) 200 + 201 + // "🎉" is 4 bytes in UTF-8 202 + // Character count: 12 203 + // Byte count: 15 204 + ``` 205 + 206 + Always use ``RichText/byteIndex(from:)`` when converting from String indices. 207 + 208 + ## Best Practices 209 + 210 + 1. **Use auto-detection**: Let ``RichText/detect(in:)`` handle facet creation 211 + 2. **Verify byte indices**: Test with emoji-heavy text to ensure correctness 212 + 3. **Handle missing DIDs**: Detected mentions have `handle` but not `did` - resolve DIDs before posting if needed 213 + 4. **Sort facets**: Facets should be sorted by `byteStart` (auto-detection does this) 214 + 215 + ## See Also 216 + 217 + - ``RichText`` 218 + - ``RichTextFacet`` 219 + - ``RichTextFeature`` 220 + - ``PostRecord`` 221 + - <doc:SocialActions>
+306
Sources/bskyKit/Documentation.docc/SocialActions.md
··· 1 + # Social Actions 2 + 3 + Create posts, likes, reposts, and manage social interactions. 4 + 5 + ## Overview 6 + 7 + bskyKit's ``RepoService`` provides methods for all write operations on Bluesky. This includes creating posts, liking and reposting content, following users, and more. 8 + 9 + ## Creating Posts 10 + 11 + ### Simple Post 12 + 13 + Create a basic text post: 14 + 15 + ```swift 16 + let repoService = await RepoService() 17 + let myDID = "did:plc:your-did-here" 18 + 19 + // Simple post 20 + let post = PostRecord(text: "Hello, Bluesky!") 21 + let response = try await repoService.createPost(post, repo: myDID) 22 + 23 + print("Post created: \(response.uri)") 24 + print("CID: \(response.cid)") 25 + ``` 26 + 27 + ### Post with Auto-Detected Facets 28 + 29 + Include mentions, links, and hashtags: 30 + 31 + ```swift 32 + let post = PostRecord.create( 33 + text: "Hey @alice.bsky.social! Check out https://swift.org #SwiftLang" 34 + ) 35 + 36 + let response = try await repoService.createPost(post, repo: myDID) 37 + ``` 38 + 39 + ### Reply to a Post 40 + 41 + Reply to an existing post: 42 + 43 + ```swift 44 + let parentPost = PostRef( 45 + uri: "at://did:plc:abc/app.bsky.feed.post/123", 46 + cid: "bafyrei..." 47 + ) 48 + 49 + // For top-level replies, root and parent are the same 50 + let reply = ReplyRef(root: parentPost, parent: parentPost) 51 + 52 + let post = PostRecord.create( 53 + text: "Great point! I agree completely.", 54 + reply: reply 55 + ) 56 + 57 + let response = try await repoService.createPost(post, repo: myDID) 58 + ``` 59 + 60 + ### Post with Quote 61 + 62 + Quote another post: 63 + 64 + ```swift 65 + let quotedPost = RecordEmbed( 66 + uri: "at://did:plc:abc/app.bsky.feed.post/123", 67 + cid: "bafyrei..." 68 + ) 69 + 70 + let post = PostRecord.create( 71 + text: "This is so true!", 72 + embed: .record(quotedPost) 73 + ) 74 + 75 + let response = try await repoService.createPost(post, repo: myDID) 76 + ``` 77 + 78 + ### Post with External Link 79 + 80 + Add a link card: 81 + 82 + ```swift 83 + let linkEmbed = ExternalEmbed( 84 + uri: "https://swift.org", 85 + title: "Swift.org", 86 + description: "Swift is a general-purpose programming language..." 87 + ) 88 + 89 + let post = PostRecord.create( 90 + text: "Check out the Swift programming language", 91 + embed: .external(linkEmbed) 92 + ) 93 + 94 + let response = try await repoService.createPost(post, repo: myDID) 95 + ``` 96 + 97 + ## Liking Posts 98 + 99 + Like a post: 100 + 101 + ```swift 102 + let likeResponse = try await repoService.like( 103 + uri: "at://did:plc:abc/app.bsky.feed.post/123", 104 + cid: "bafyrei...", 105 + repo: myDID 106 + ) 107 + 108 + print("Like created: \(likeResponse.uri)") 109 + ``` 110 + 111 + Unlike a post: 112 + 113 + ```swift 114 + // Use the URI from the like record (viewer.like from the post) 115 + try await repoService.unlike( 116 + uri: "at://did:plc:mydid/app.bsky.feed.like/xyz", 117 + repo: myDID 118 + ) 119 + ``` 120 + 121 + ## Reposting 122 + 123 + Repost a post: 124 + 125 + ```swift 126 + let repostResponse = try await repoService.repost( 127 + uri: "at://did:plc:abc/app.bsky.feed.post/123", 128 + cid: "bafyrei...", 129 + repo: myDID 130 + ) 131 + 132 + print("Repost created: \(repostResponse.uri)") 133 + ``` 134 + 135 + Remove a repost: 136 + 137 + ```swift 138 + try await repoService.unrepost( 139 + uri: "at://did:plc:mydid/app.bsky.feed.repost/xyz", 140 + repo: myDID 141 + ) 142 + ``` 143 + 144 + ## Following Users 145 + 146 + Follow a user: 147 + 148 + ```swift 149 + let followResponse = try await repoService.follow( 150 + did: "did:plc:user-to-follow", 151 + repo: myDID 152 + ) 153 + 154 + print("Follow created: \(followResponse.uri)") 155 + ``` 156 + 157 + Unfollow a user: 158 + 159 + ```swift 160 + // Use the URI from viewer.following 161 + try await repoService.unfollow( 162 + uri: "at://did:plc:mydid/app.bsky.graph.follow/xyz", 163 + repo: myDID 164 + ) 165 + ``` 166 + 167 + ## Blocking Users 168 + 169 + Block a user: 170 + 171 + ```swift 172 + let blockResponse = try await repoService.block( 173 + did: "did:plc:user-to-block", 174 + repo: myDID 175 + ) 176 + ``` 177 + 178 + Unblock: 179 + 180 + ```swift 181 + try await repoService.unblock( 182 + uri: "at://did:plc:mydid/app.bsky.graph.block/xyz", 183 + repo: myDID 184 + ) 185 + ``` 186 + 187 + ## Low-Level Record Operations 188 + 189 + For advanced use cases, use the generic record methods: 190 + 191 + ### Create Any Record 192 + 193 + ```swift 194 + let record: [String: Any] = [ 195 + "$type": "app.bsky.feed.post", 196 + "text": "Hello world", 197 + "createdAt": ISO8601DateFormatter().string(from: Date()) 198 + ] 199 + 200 + let response = try await repoService.createRecord( 201 + repo: myDID, 202 + collection: "app.bsky.feed.post", 203 + record: record 204 + ) 205 + ``` 206 + 207 + ### Delete Any Record 208 + 209 + ```swift 210 + try await repoService.deleteRecord( 211 + repo: myDID, 212 + collection: "app.bsky.feed.post", 213 + rkey: "abc123" 214 + ) 215 + ``` 216 + 217 + ### Get a Record 218 + 219 + ```swift 220 + let record = try await repoService.getRecord( 221 + repo: "did:plc:abc", 222 + collection: "app.bsky.feed.post", 223 + rkey: "xyz789" 224 + ) 225 + 226 + print("Text: \(record.value.text ?? "")") 227 + ``` 228 + 229 + ### List Records 230 + 231 + ```swift 232 + let records = try await repoService.listRecords( 233 + repo: myDID, 234 + collection: "app.bsky.feed.post", 235 + limit: 100 236 + ) 237 + 238 + for item in records.records { 239 + print("\(item.uri): \(item.value.text ?? "")") 240 + } 241 + ``` 242 + 243 + ## PostRecord Reference 244 + 245 + ```swift 246 + public struct PostRecord: Sendable { 247 + public let text: String 248 + public let facets: [RichTextFacet]? 249 + public let reply: ReplyRef? 250 + public let embed: PostEmbed? 251 + public let langs: [String]? 252 + public let createdAt: Date 253 + 254 + // Create with auto-detected facets 255 + public static func create( 256 + text: String, 257 + reply: ReplyRef? = nil, 258 + embed: PostEmbed? = nil, 259 + langs: [String]? = nil 260 + ) -> PostRecord 261 + } 262 + ``` 263 + 264 + ## Embed Types 265 + 266 + ```swift 267 + public enum PostEmbed: Sendable { 268 + case images([ImageEmbed]) // Up to 4 images 269 + case external(ExternalEmbed) // Link card 270 + case record(RecordEmbed) // Quote post 271 + case recordWithMedia(RecordEmbed, [ImageEmbed]) // Quote with images 272 + } 273 + ``` 274 + 275 + ## Error Handling 276 + 277 + ```swift 278 + do { 279 + let response = try await repoService.createPost(post, repo: myDID) 280 + } catch RepoError.invalidUri(let uri) { 281 + print("Invalid URI: \(uri)") 282 + } catch let error as AtError { 283 + switch error { 284 + case .message(let msg): 285 + print("API error: \(msg.error)") 286 + case .network(let networkError): 287 + print("Network error: \(networkError)") 288 + } 289 + } 290 + ``` 291 + 292 + ## Best Practices 293 + 294 + 1. **Always use your own DID** for the `repo` parameter 295 + 2. **Store record URIs** from responses - you'll need them for unlike/unrepost/unfollow 296 + 3. **Use PostRecord.create()** for automatic facet detection 297 + 4. **Handle errors** - network issues and API errors are common 298 + 5. **Rate limit** your requests - don't spam the API 299 + 300 + ## See Also 301 + 302 + - ``RepoService`` 303 + - ``PostRecord`` 304 + - ``ReplyRef`` 305 + - ``PostEmbed`` 306 + - <doc:RichTextGuide>
+305
Sources/bskyKit/Documentation.docc/SocialGraph.md
··· 1 + # Social Graph 2 + 3 + Manage follows, followers, blocks, and mutes. 4 + 5 + ## Overview 6 + 7 + The social graph in Bluesky consists of relationships between users: follows, followers, blocks, and mutes. bskyKit provides APIs to query these relationships and modify them. 8 + 9 + ## Follows 10 + 11 + ### Get Who a User Follows 12 + 13 + Use ``BskyService/getFollows(for:limit:cursor:)`` to see who a user follows: 14 + 15 + ```swift 16 + let service = await BskyService() 17 + 18 + let follows = try await service.getFollows( 19 + for: "did:plc:abc123", 20 + limit: 50 21 + ) 22 + 23 + print("\(follows.subject.handle) follows \(follows.follows.count) users:") 24 + 25 + for follow in follows.follows { 26 + print(" @\(follow.handle)") 27 + if let displayName = follow.displayName { 28 + print(" \(displayName)") 29 + } 30 + } 31 + 32 + // Paginate with cursor 33 + if let cursor = follows.cursor { 34 + let nextPage = try await service.getFollows( 35 + for: "did:plc:abc123", 36 + limit: 50, 37 + cursor: cursor 38 + ) 39 + } 40 + ``` 41 + 42 + ### Get a User's Followers 43 + 44 + Use ``BskyService/getFollowers(for:limit:cursor:)`` to see who follows a user: 45 + 46 + ```swift 47 + let followers = try await service.getFollowers( 48 + for: "did:plc:abc123", 49 + limit: 50 50 + ) 51 + 52 + print("\(followers.subject.handle) has \(followers.followers.count) followers:") 53 + 54 + for follower in followers.followers { 55 + print(" @\(follower.handle)") 56 + } 57 + ``` 58 + 59 + ### Follow a User 60 + 61 + Use ``RepoService/follow(did:repo:)`` to follow someone: 62 + 63 + ```swift 64 + let repoService = await RepoService() 65 + 66 + let response = try await repoService.follow( 67 + did: "did:plc:user-to-follow", 68 + repo: myDID 69 + ) 70 + 71 + print("Follow created: \(response.uri)") 72 + // Save this URI to unfollow later 73 + ``` 74 + 75 + ### Unfollow a User 76 + 77 + Use ``RepoService/unfollow(uri:repo:)`` to unfollow: 78 + 79 + ```swift 80 + // The URI comes from viewer.following or from the follow response 81 + try await repoService.unfollow( 82 + uri: "at://did:plc:mydid/app.bsky.graph.follow/abc123", 83 + repo: myDID 84 + ) 85 + ``` 86 + 87 + ## Blocks 88 + 89 + ### Get Blocked Users 90 + 91 + Use ``BskyService/getBlocks(limit:cursor:)`` to list users you've blocked: 92 + 93 + ```swift 94 + let blocks = try await service.getBlocks(limit: 50) 95 + 96 + for blocked in blocks.blocks { 97 + print("Blocked: @\(blocked.handle)") 98 + } 99 + ``` 100 + 101 + ### Block a User 102 + 103 + Use ``RepoService/block(did:repo:)`` to block someone: 104 + 105 + ```swift 106 + let response = try await repoService.block( 107 + did: "did:plc:user-to-block", 108 + repo: myDID 109 + ) 110 + 111 + print("Block created: \(response.uri)") 112 + ``` 113 + 114 + ### Unblock a User 115 + 116 + Use ``RepoService/unblock(uri:repo:)`` to remove a block: 117 + 118 + ```swift 119 + try await repoService.unblock( 120 + uri: "at://did:plc:mydid/app.bsky.graph.block/xyz", 121 + repo: myDID 122 + ) 123 + ``` 124 + 125 + ## Mutes 126 + 127 + ### Get Muted Users 128 + 129 + Use ``BskyService/getMutes(limit:cursor:)`` to list muted users: 130 + 131 + ```swift 132 + let mutes = try await service.getMutes(limit: 50) 133 + 134 + for muted in mutes.mutes { 135 + print("Muted: @\(muted.handle)") 136 + } 137 + ``` 138 + 139 + > Note: Muting is handled differently from blocks - mute/unmute operations use dedicated endpoints rather than record creation. 140 + 141 + ## Checking Relationships 142 + 143 + The ``Viewer`` struct on profiles indicates the relationship: 144 + 145 + ```swift 146 + let profile = try await service.getProfile(for: handle) 147 + 148 + if let viewer = profile.viewer { 149 + // Check if you follow them 150 + if let followUri = viewer.following { 151 + print("You follow this user") 152 + // Store followUri for unfollowing 153 + } 154 + 155 + // Check if they follow you 156 + if let _ = viewer.followedBy { 157 + print("This user follows you") 158 + } 159 + 160 + // Check mutual follow 161 + if viewer.following != nil && viewer.followedBy != nil { 162 + print("Mutual follow!") 163 + } 164 + 165 + // Check mute status 166 + if viewer.muted == true { 167 + print("You have muted this user") 168 + } 169 + 170 + // Check if they blocked you 171 + if viewer.blockedBy == true { 172 + print("This user has blocked you") 173 + } 174 + 175 + // Check if you blocked them 176 + if let _ = viewer.blocking { 177 + print("You have blocked this user") 178 + } 179 + } 180 + ``` 181 + 182 + ## Model Reference 183 + 184 + ### Follows Response 185 + 186 + ```swift 187 + public struct Follows: Codable, Sendable { 188 + public let subject: FollowSubject 189 + public let follows: [FollowProfile] 190 + public let cursor: String? 191 + } 192 + 193 + public struct FollowSubject: Codable, Sendable { 194 + public let did: String 195 + public let handle: String 196 + public let displayName: String? 197 + public let avatar: String? 198 + } 199 + 200 + public struct FollowProfile: Codable, Sendable, Identifiable { 201 + public let did: String 202 + public let handle: String 203 + public let displayName: String? 204 + public let avatar: String? 205 + public let description: String? 206 + public let indexedAt: Date? 207 + public let viewer: Viewer? 208 + 209 + public var id: String { did } 210 + } 211 + ``` 212 + 213 + ### Followers Response 214 + 215 + ```swift 216 + public struct Followers: Codable, Sendable { 217 + public let subject: FollowSubject 218 + public let followers: [FollowProfile] 219 + public let cursor: String? 220 + } 221 + ``` 222 + 223 + ### Blocks Response 224 + 225 + ```swift 226 + public struct Blocks: Codable, Sendable { 227 + public let blocks: [BlockedProfile] 228 + public let cursor: String? 229 + } 230 + 231 + public struct BlockedProfile: Codable, Sendable, Identifiable { 232 + public let did: String 233 + public let handle: String 234 + public let displayName: String? 235 + public let avatar: String? 236 + public let viewer: Viewer? 237 + 238 + public var id: String { did } 239 + } 240 + ``` 241 + 242 + ### Mutes Response 243 + 244 + ```swift 245 + public struct Mutes: Codable, Sendable { 246 + public let mutes: [MutedProfile] 247 + public let cursor: String? 248 + } 249 + 250 + public struct MutedProfile: Codable, Sendable, Identifiable { 251 + public let did: String 252 + public let handle: String 253 + public let displayName: String? 254 + public let avatar: String? 255 + public let viewer: Viewer? 256 + 257 + public var id: String { did } 258 + } 259 + ``` 260 + 261 + ### Viewer 262 + 263 + ```swift 264 + public struct Viewer: Codable, Sendable { 265 + public let muted: Bool? 266 + public let mutedByList: String? 267 + public let blockedBy: Bool? 268 + public let blocking: String? 269 + public let following: String? 270 + public let followedBy: String? 271 + } 272 + ``` 273 + 274 + ## Pagination Pattern 275 + 276 + All graph endpoints support cursor-based pagination: 277 + 278 + ```swift 279 + func fetchAllFollows(for did: String) async throws -> [FollowProfile] { 280 + var allFollows: [FollowProfile] = [] 281 + var cursor: String? = nil 282 + 283 + repeat { 284 + let response = try await service.getFollows( 285 + for: did, 286 + limit: 100, 287 + cursor: cursor 288 + ) 289 + allFollows.append(contentsOf: response.follows) 290 + cursor = response.cursor 291 + } while cursor != nil 292 + 293 + return allFollows 294 + } 295 + ``` 296 + 297 + ## See Also 298 + 299 + - ``BskyService`` 300 + - ``RepoService`` 301 + - ``Follows`` 302 + - ``Followers`` 303 + - ``Blocks`` 304 + - ``Mutes`` 305 + - ``Viewer``
+265
Sources/bskyKit/Documentation.docc/TimelineAndFeeds.md
··· 1 + # Timeline and Feeds 2 + 3 + Read home timelines, author feeds, and work with posts. 4 + 5 + ## Overview 6 + 7 + Bluesky organizes content through timelines and feeds. The home timeline shows posts from accounts you follow, while author feeds show posts from a specific user. bskyKit provides APIs to read, paginate, and interact with this content. 8 + 9 + ## Reading the Home Timeline 10 + 11 + Use ``BskyService/getTimeline(limit:cursor:)`` to fetch the authenticated user's home timeline: 12 + 13 + ```swift 14 + let service = await BskyService() 15 + 16 + // Fetch the latest 50 posts 17 + let timeline = try await service.getTimeline(limit: 50) 18 + 19 + for item in timeline.feed { 20 + let post = item.post 21 + let author = post.author 22 + 23 + print("@\(author.handle)") 24 + if let displayName = author.displayName { 25 + print(" \(displayName)") 26 + } 27 + print(" \(post.record.text)") 28 + print(" Likes: \(post.likeCount) | Reposts: \(post.repostCount) | Replies: \(post.replyCount)") 29 + print("") 30 + } 31 + ``` 32 + 33 + ## Pagination 34 + 35 + Use cursors to paginate through large result sets: 36 + 37 + ```swift 38 + var cursor: String? = nil 39 + var allPosts: [TimelineItem] = [] 40 + 41 + repeat { 42 + let timeline = try await service.getTimeline(limit: 100, cursor: cursor) 43 + allPosts.append(contentsOf: timeline.feed) 44 + cursor = timeline.cursor.isEmpty ? nil : timeline.cursor 45 + 46 + // Limit to 500 posts for this example 47 + if allPosts.count >= 500 { break } 48 + } while cursor != nil 49 + 50 + print("Fetched \(allPosts.count) posts") 51 + ``` 52 + 53 + ## Author Feeds 54 + 55 + Fetch posts from a specific user: 56 + 57 + ```swift 58 + // Get posts by a specific user 59 + let authorFeed = try await service.getAuthorFeed( 60 + for: "did:plc:abc123", 61 + limit: 25 62 + ) 63 + 64 + for item in authorFeed.feed { 65 + print(item.post.record.text) 66 + } 67 + 68 + // Paginate author feed 69 + if let cursor = authorFeed.cursor { 70 + let nextPage = try await service.getAuthorFeed( 71 + for: "did:plc:abc123", 72 + limit: 25, 73 + cursor: cursor 74 + ) 75 + } 76 + ``` 77 + 78 + ## Post Threads 79 + 80 + View a post and its replies: 81 + 82 + ```swift 83 + let postUri = "at://did:plc:abc123/app.bsky.feed.post/xyz789" 84 + 85 + let thread = try await service.getPostThread(uri: postUri, depth: 6) 86 + 87 + // thread.thread contains the post and nested replies 88 + print("Thread fetched with depth: 6") 89 + ``` 90 + 91 + ## Batch Fetching Posts 92 + 93 + Fetch multiple posts by URI: 94 + 95 + ```swift 96 + let uris = [ 97 + "at://did:plc:abc/app.bsky.feed.post/123", 98 + "at://did:plc:def/app.bsky.feed.post/456" 99 + ] 100 + 101 + let posts = try await service.getPosts(uris: uris) 102 + 103 + for post in posts.posts { 104 + print(post.record.text) 105 + } 106 + ``` 107 + 108 + ## Post Interactions 109 + 110 + ### Get Likes 111 + 112 + See who liked a post: 113 + 114 + ```swift 115 + let likes = try await service.getLikes( 116 + uri: "at://did:plc:abc/app.bsky.feed.post/123", 117 + limit: 50 118 + ) 119 + 120 + print("Total likes: \(likes.likes.count)") 121 + for like in likes.likes { 122 + print(" @\(like.actor.handle)") 123 + } 124 + ``` 125 + 126 + ### Get Reposts 127 + 128 + See who reposted: 129 + 130 + ```swift 131 + let reposts = try await service.getRepostedBy( 132 + uri: "at://did:plc:abc/app.bsky.feed.post/123", 133 + limit: 50 134 + ) 135 + 136 + for repost in reposts.repostedBy { 137 + print(" @\(repost.handle)") 138 + } 139 + ``` 140 + 141 + ## Feed Generators 142 + 143 + Fetch custom feed generators: 144 + 145 + ```swift 146 + let feedUris = [ 147 + "at://did:plc:xxx/app.bsky.feed.generator/whats-hot", 148 + "at://did:plc:yyy/app.bsky.feed.generator/tech" 149 + ] 150 + 151 + let generators = try await service.getFeedGenerators(for: feedUris) 152 + 153 + for feed in generators.feeds { 154 + print("\(feed.displayName ?? feed.uri)") 155 + print(" Likes: \(feed.likeCount ?? 0)") 156 + print(" Creator: @\(feed.creator.handle)") 157 + } 158 + ``` 159 + 160 + ## Understanding Timeline Structure 161 + 162 + ### Timeline 163 + 164 + ```swift 165 + public struct Timeline: Codable, Sendable { 166 + public var feed: [TimelineItem] 167 + public var cursor: String 168 + } 169 + ``` 170 + 171 + ### TimelineItem 172 + 173 + Each item in the timeline wraps a post and optional reply context: 174 + 175 + ```swift 176 + public struct TimelineItem: Codable, Sendable, Identifiable { 177 + public let post: Post 178 + public let reply: Reply? 179 + 180 + public var id: String { 181 + "\(post.uri ?? "")-\(post.cid ?? "")" 182 + } 183 + } 184 + ``` 185 + 186 + ### Post 187 + 188 + ```swift 189 + public struct Post: Codable, Sendable { 190 + public let uri: String? 191 + public let cid: String? 192 + public let author: Author 193 + public let record: Record 194 + public let replyCount: Int 195 + public let repostCount: Int 196 + public let likeCount: Int 197 + public let indexedAt: String 198 + public let viewer: Viewer 199 + public let labels: [String] 200 + public let embed: Embed? 201 + } 202 + ``` 203 + 204 + ### Record 205 + 206 + The post content: 207 + 208 + ```swift 209 + public struct Record: Codable, Sendable { 210 + public let text: String 211 + public let type: String 212 + public let langs: [String]? 213 + public let reply: ReplyDetail? 214 + public let createdAt: String 215 + public let embed: Embed? 216 + public let facets: [Facet]? 217 + } 218 + ``` 219 + 220 + ## Handling Embeds 221 + 222 + Posts can contain various types of embedded content: 223 + 224 + ```swift 225 + if let embed = post.embed { 226 + switch EmbedType(rawValue: embed.type) { 227 + case .image: 228 + // Image embed 229 + if let images = embed.images { 230 + for image in images { 231 + print("Image: \(image.alt)") 232 + } 233 + } 234 + 235 + case .external: 236 + // Link preview 237 + if let external = embed.external { 238 + print("Link: \(external.title)") 239 + print("URL: \(external.uri ?? "")") 240 + } 241 + 242 + case .record: 243 + // Quote post 244 + if let record = embed.record { 245 + print("Quote: \(record.value?.text ?? "")") 246 + } 247 + 248 + case .recordWithMedia: 249 + // Quote post with images 250 + print("Quote with media") 251 + 252 + default: 253 + break 254 + } 255 + } 256 + ``` 257 + 258 + ## See Also 259 + 260 + - ``BskyService`` 261 + - ``Timeline`` 262 + - ``TimelineItem`` 263 + - ``Post`` 264 + - ``AuthorFeed`` 265 + - <doc:RichTextGuide>
+180
Sources/bskyKit/Documentation.docc/WorkingWithProfiles.md
··· 1 + # Working with Profiles 2 + 3 + Fetch, search, and manage user profiles on Bluesky. 4 + 5 + ## Overview 6 + 7 + User profiles are central to the Bluesky experience. bskyKit provides comprehensive APIs for fetching individual profiles, batch fetching multiple profiles, and searching for users. 8 + 9 + ## Fetching a Single Profile 10 + 11 + Use ``BskyService/getProfile(for:)`` to fetch a profile by handle or DID: 12 + 13 + ```swift 14 + let service = await BskyService() 15 + 16 + // By handle 17 + let profile = try await service.getProfile(for: "alice.bsky.social") 18 + 19 + // By DID 20 + let profileByDID = try await service.getProfile(for: "did:plc:abc123...") 21 + ``` 22 + 23 + The returned ``Profile`` contains: 24 + 25 + | Property | Type | Description | 26 + |----------|------|-------------| 27 + | `did` | `String` | The decentralized identifier | 28 + | `handle` | `String` | The user's handle (e.g., alice.bsky.social) | 29 + | `displayName` | `String?` | Optional display name | 30 + | `description` | `String?` | User bio | 31 + | `avatar` | `String?` | URL to avatar image | 32 + | `banner` | `String?` | URL to banner image | 33 + | `followersCount` | `Int?` | Number of followers | 34 + | `followsCount` | `Int?` | Number of accounts followed | 35 + | `postsCount` | `Int?` | Number of posts | 36 + | `viewer` | `Viewer?` | Relationship with authenticated user | 37 + 38 + ## Batch Fetching Profiles 39 + 40 + When you need multiple profiles, use ``BskyService/getProfiles(for:)`` for efficiency: 41 + 42 + ```swift 43 + let dids = [ 44 + "did:plc:abc123", 45 + "did:plc:def456", 46 + "did:plc:ghi789" 47 + ] 48 + 49 + let profiles = try await service.getProfiles(for: dids) 50 + 51 + for profile in profiles.profiles { 52 + print("@\(profile.handle): \(profile.displayName ?? "")") 53 + } 54 + ``` 55 + 56 + > Tip: Batch fetching is more efficient than multiple individual requests when you need several profiles. 57 + 58 + ## Searching for Users 59 + 60 + ### Full Search 61 + 62 + Use ``BskyService/searchActors(query:limit:)`` for comprehensive user search: 63 + 64 + ```swift 65 + let results = try await service.searchActors(query: "swift developer", limit: 25) 66 + 67 + for actor in results.actors { 68 + print("@\(actor.handle)") 69 + print(" Name: \(actor.displayName ?? "N/A")") 70 + print(" Bio: \(actor.description ?? "N/A")") 71 + } 72 + 73 + // Handle pagination 74 + if let cursor = results.cursor { 75 + // Fetch next page with cursor 76 + } 77 + ``` 78 + 79 + ### Typeahead Search 80 + 81 + For autocomplete functionality, use ``BskyService/searchActorsTypeahead(query:limit:)``: 82 + 83 + ```swift 84 + // Fast search for autocomplete UI 85 + let typeahead = try await service.searchActorsTypeahead(query: "ali", limit: 5) 86 + 87 + for actor in typeahead.actors { 88 + print("@\(actor.handle)") 89 + } 90 + ``` 91 + 92 + > Note: Typeahead search is optimized for speed and returns fewer fields than full search. 93 + 94 + ## Understanding Viewer State 95 + 96 + The ``Viewer`` struct indicates the relationship between the authenticated user and a profile: 97 + 98 + ```swift 99 + if let viewer = profile.viewer { 100 + if viewer.muted == true { 101 + print("You have muted this user") 102 + } 103 + 104 + if viewer.blockedBy == true { 105 + print("This user has blocked you") 106 + } 107 + 108 + if let followUri = viewer.following { 109 + print("You follow this user") 110 + // followUri is the AT-URI of your follow record 111 + } 112 + 113 + if let followedByUri = viewer.followedBy { 114 + print("This user follows you") 115 + } 116 + } 117 + ``` 118 + 119 + ## User Preferences 120 + 121 + Fetch the authenticated user's preferences: 122 + 123 + ```swift 124 + let preferences = try await service.getPreferences() 125 + 126 + // Preferences contain saved feeds, pinned items, etc. 127 + for savedFeed in preferences.saved { 128 + print("Saved: \(savedFeed)") 129 + } 130 + ``` 131 + 132 + ## Profile Model Reference 133 + 134 + ### Profile 135 + 136 + ```swift 137 + public struct Profile: Codable, Sendable, Identifiable { 138 + public let did: String 139 + public let handle: String 140 + public let displayName: String? 141 + public let description: String? 142 + public let avatar: String? 143 + public let banner: String? 144 + public let followsCount: Int? 145 + public let followersCount: Int? 146 + public let postsCount: Int? 147 + public let indexedAt: Date? 148 + public let viewer: Viewer? 149 + public let labels: [AuthorLabels]? 150 + 151 + public var id: String { did } 152 + } 153 + ``` 154 + 155 + ### ActorProfile 156 + 157 + Used in search results: 158 + 159 + ```swift 160 + public struct ActorProfile: Codable, Sendable, Identifiable { 161 + public let did: String 162 + public let handle: String 163 + public let displayName: String? 164 + public let avatar: String? 165 + public let description: String? 166 + public let indexedAt: Date? 167 + public let viewer: Viewer? 168 + public let labels: [AuthorLabels]? 169 + 170 + public var id: String { did } 171 + } 172 + ``` 173 + 174 + ## See Also 175 + 176 + - ``BskyService`` 177 + - ``Profile`` 178 + - ``ActorProfile`` 179 + - ``Viewer`` 180 + - <doc:SocialGraph>
+116
Sources/bskyKit/Documentation.docc/bskyKit.md
··· 1 + # ``bskyKit`` 2 + 3 + A Swift SDK for interacting with Bluesky social network APIs. 4 + 5 + ## Overview 6 + 7 + bskyKit provides a type-safe, Swift-native interface to the Bluesky AT Protocol APIs. Built on top of CoreATProtocol, it offers comprehensive support for reading and writing social data including profiles, timelines, posts, follows, and notifications. 8 + 9 + ### Key Features 10 + 11 + - **Profile Management**: Fetch user profiles, search for users, and get preferences 12 + - **Timeline & Feeds**: Read home timelines, author feeds, and custom feed generators 13 + - **Social Graph**: Manage follows, followers, blocks, and mutes 14 + - **Rich Text**: Auto-detect mentions, links, and hashtags with proper byte indexing 15 + - **Write Operations**: Create posts, likes, reposts, follows, and blocks 16 + - **Notifications**: List notifications and manage read state 17 + 18 + ### Quick Start 19 + 20 + ```swift 21 + import bskyKit 22 + import CoreATProtocol 23 + 24 + // Configure the environment 25 + await setup( 26 + hostURL: "https://bsky.social", 27 + accessJWT: "your-access-token", 28 + refreshJWT: nil 29 + ) 30 + 31 + // Fetch a profile 32 + let service = await BskyService() 33 + let profile = try await service.getProfile(for: "alice.bsky.social") 34 + print("@\(profile.handle): \(profile.displayName ?? "")") 35 + 36 + // Get timeline 37 + let timeline = try await service.getTimeline(limit: 20) 38 + for item in timeline.feed { 39 + print(item.post.record.text) 40 + } 41 + ``` 42 + 43 + ### Architecture 44 + 45 + bskyKit is organized into several key components: 46 + 47 + - **BskyService**: The main entry point for reading Bluesky data 48 + - **RepoService**: Handles write operations (create/delete records) 49 + - **RichText**: Utilities for handling rich text with facets 50 + - **Models**: Type-safe representations of Bluesky data structures 51 + 52 + ## Topics 53 + 54 + ### Essentials 55 + 56 + - <doc:GettingStarted> 57 + - ``BskyService`` 58 + - ``RepoService`` 59 + 60 + ### Working with Profiles 61 + 62 + - <doc:WorkingWithProfiles> 63 + - ``Profile`` 64 + - ``ActorProfile`` 65 + - ``Viewer`` 66 + 67 + ### Timeline and Feeds 68 + 69 + - <doc:TimelineAndFeeds> 70 + - ``Timeline`` 71 + - ``TimelineItem`` 72 + - ``Post`` 73 + - ``AuthorFeed`` 74 + 75 + ### Rich Text 76 + 77 + - <doc:RichTextGuide> 78 + - ``RichText`` 79 + - ``RichTextFacet`` 80 + - ``RichTextFeature`` 81 + 82 + ### Social Actions 83 + 84 + - <doc:SocialActions> 85 + - ``PostRecord`` 86 + - ``ReplyRef`` 87 + - ``PostEmbed`` 88 + 89 + ### Social Graph 90 + 91 + - <doc:SocialGraph> 92 + - ``Follows`` 93 + - ``Followers`` 94 + - ``FollowProfile`` 95 + 96 + ### Notifications 97 + 98 + - <doc:Notifications> 99 + - ``NotificationsResponse`` 100 + - ``Notification`` 101 + - ``NotificationReason`` 102 + 103 + ### Response Types 104 + 105 + - ``CreateRecordResponse`` 106 + - ``GetRecordResponse`` 107 + - ``ListRecordsResponse`` 108 + 109 + ### Supporting Types 110 + 111 + - ``Feed`` 112 + - ``Feeds`` 113 + - ``Creator`` 114 + - ``Preferences`` 115 + - ``Blocks`` 116 + - ``Mutes``
+14
Sources/bskyKit/Models/AuthorFeed.swift
··· 1 + // 2 + // AuthorFeed.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Response from app.bsky.feed.getAuthorFeed 11 + public struct AuthorFeed: Codable, Sendable { 12 + public let feed: [TimelineItem] 13 + public let cursor: String? 14 + }
+18
Sources/bskyKit/Models/Creator.swift
··· 1 + // 2 + // Creator.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 10/11/25. 6 + // 7 + 8 + public struct Creator: Codable, Sendable, Identifiable { 9 + public let did: String 10 + public let handle: String 11 + public let displayName: String? 12 + public let avatar: String? 13 + public let viewer: Viewer? 14 + public let labels: [AuthorLabels]? 15 + 16 + /// Stable identifier based on DID 17 + public var id: String { did } 18 + }
+28
Sources/bskyKit/Models/Feed.swift
··· 1 + // 2 + // Feed.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 10/11/25. 6 + // 7 + 8 + import Foundation 9 + 10 + public struct Feed: Codable, Sendable, Identifiable { 11 + public let uri: String 12 + public let cid: String 13 + public let did: String 14 + public let creator: Creator 15 + public let displayName: String 16 + public let description: String? 17 + public let avatar: String? 18 + public let likeCount: Int? 19 + public let viewer: Viewer? 20 + public let indexedAt: Date? 21 + 22 + /// Stable identifier based on URI 23 + public var id: String { uri } 24 + } 25 + 26 + public struct Feeds: Codable, Sendable { 27 + public let feeds: [Feed] 28 + }
+59
Sources/bskyKit/Models/Follows.swift
··· 1 + // 2 + // Follows.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Response from app.bsky.graph.getFollows 11 + public struct Follows: Codable, Sendable { 12 + /// The subject (user) whose follows are being listed 13 + public let subject: FollowSubject 14 + 15 + /// List of accounts the subject follows 16 + public let follows: [FollowProfile] 17 + 18 + /// Pagination cursor for fetching more results 19 + public let cursor: String? 20 + } 21 + 22 + /// The subject of a follows query 23 + public struct FollowSubject: Codable, Sendable { 24 + public let did: String 25 + public let handle: String 26 + public let displayName: String? 27 + public let avatar: String? 28 + public let description: String? 29 + public let indexedAt: Date? 30 + public let viewer: Viewer? 31 + public let labels: [AuthorLabels]? 32 + } 33 + 34 + /// A profile in the follows list 35 + public struct FollowProfile: Codable, Sendable, Identifiable { 36 + public let did: String 37 + public let handle: String 38 + public let displayName: String? 39 + public let avatar: String? 40 + public let description: String? 41 + public let indexedAt: Date? 42 + public let viewer: Viewer? 43 + public let labels: [AuthorLabels]? 44 + 45 + /// Stable identifier based on DID 46 + public var id: String { did } 47 + } 48 + 49 + /// Response from app.bsky.graph.getFollowers 50 + public struct Followers: Codable, Sendable { 51 + /// The subject (user) whose followers are being listed 52 + public let subject: FollowSubject 53 + 54 + /// List of accounts following the subject 55 + public let followers: [FollowProfile] 56 + 57 + /// Pagination cursor for fetching more results 58 + public let cursor: String? 59 + }
+48
Sources/bskyKit/Models/Graph.swift
··· 1 + // 2 + // Graph.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Response from app.bsky.graph.getBlocks 11 + public struct Blocks: Codable, Sendable { 12 + public let blocks: [BlockedProfile] 13 + public let cursor: String? 14 + } 15 + 16 + /// A blocked profile 17 + public struct BlockedProfile: Codable, Sendable, Identifiable { 18 + public let did: String 19 + public let handle: String 20 + public let displayName: String? 21 + public let avatar: String? 22 + public let description: String? 23 + public let indexedAt: Date? 24 + public let viewer: Viewer? 25 + public let labels: [AuthorLabels]? 26 + 27 + public var id: String { did } 28 + } 29 + 30 + /// Response from app.bsky.graph.getMutes 31 + public struct Mutes: Codable, Sendable { 32 + public let mutes: [MutedProfile] 33 + public let cursor: String? 34 + } 35 + 36 + /// A muted profile 37 + public struct MutedProfile: Codable, Sendable, Identifiable { 38 + public let did: String 39 + public let handle: String 40 + public let displayName: String? 41 + public let avatar: String? 42 + public let description: String? 43 + public let indexedAt: Date? 44 + public let viewer: Viewer? 45 + public let labels: [AuthorLabels]? 46 + 47 + public var id: String { did } 48 + }
+59
Sources/bskyKit/Models/Interactions.swift
··· 1 + // 2 + // Interactions.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Response from app.bsky.feed.getLikes 11 + public struct Likes: Codable, Sendable { 12 + public let uri: String 13 + public let cid: String? 14 + public let likes: [Like] 15 + public let cursor: String? 16 + } 17 + 18 + /// A single like 19 + public struct Like: Codable, Sendable, Identifiable { 20 + public let actor: LikeActor 21 + public let createdAt: Date 22 + public let indexedAt: Date 23 + 24 + public var id: String { "\(actor.did)-\(indexedAt.timeIntervalSince1970)" } 25 + } 26 + 27 + /// Actor who liked a post 28 + public struct LikeActor: Codable, Sendable, Identifiable { 29 + public let did: String 30 + public let handle: String 31 + public let displayName: String? 32 + public let avatar: String? 33 + public let viewer: Viewer? 34 + public let labels: [AuthorLabels]? 35 + 36 + public var id: String { did } 37 + } 38 + 39 + /// Response from app.bsky.feed.getRepostedBy 40 + public struct RepostedBy: Codable, Sendable { 41 + public let uri: String 42 + public let cid: String? 43 + public let repostedBy: [RepostActor] 44 + public let cursor: String? 45 + } 46 + 47 + /// Actor who reposted a post 48 + public struct RepostActor: Codable, Sendable, Identifiable { 49 + public let did: String 50 + public let handle: String 51 + public let displayName: String? 52 + public let avatar: String? 53 + public let description: String? 54 + public let indexedAt: Date? 55 + public let viewer: Viewer? 56 + public let labels: [AuthorLabels]? 57 + 58 + public var id: String { did } 59 + }
+73
Sources/bskyKit/Models/Notifications.swift
··· 1 + // 2 + // Notifications.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Response from app.bsky.notification.listNotifications 11 + public struct NotificationsResponse: Codable, Sendable { 12 + public let notifications: [Notification] 13 + public let cursor: String? 14 + public let seenAt: Date? 15 + } 16 + 17 + /// A single notification 18 + public struct Notification: Codable, Sendable, Identifiable { 19 + public let uri: String 20 + public let cid: String 21 + public let author: NotificationAuthor 22 + public let reason: NotificationReason 23 + public let reasonSubject: String? 24 + public let record: NotificationRecord? 25 + public let isRead: Bool 26 + public let indexedAt: Date 27 + 28 + public var id: String { uri } 29 + } 30 + 31 + /// Author of a notification 32 + public struct NotificationAuthor: Codable, Sendable, Identifiable { 33 + public let did: String 34 + public let handle: String 35 + public let displayName: String? 36 + public let avatar: String? 37 + public let viewer: Viewer? 38 + public let labels: [AuthorLabels]? 39 + 40 + public var id: String { did } 41 + } 42 + 43 + /// Reason for a notification 44 + public enum NotificationReason: String, Codable, Sendable { 45 + case like 46 + case repost 47 + case follow 48 + case mention 49 + case reply 50 + case quote 51 + case starterpackJoined = "starterpack-joined" 52 + } 53 + 54 + /// Record attached to notification (simplified) 55 + public struct NotificationRecord: Codable, Sendable { 56 + public let type: String? 57 + public let text: String? 58 + public let createdAt: Date? 59 + 60 + enum CodingKeys: String, CodingKey { 61 + case type = "$type" 62 + case text 63 + case createdAt 64 + } 65 + } 66 + 67 + /// Response from app.bsky.notification.getUnreadCount 68 + public struct UnreadCount: Codable, Sendable { 69 + public let count: Int 70 + } 71 + 72 + /// Empty response for procedures like updateSeen 73 + public struct EmptyResponse: Codable, Sendable {}
+137
Sources/bskyKit/Models/PostThread.swift
··· 1 + // 2 + // PostThread.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Response from app.bsky.feed.getPostThread 11 + public struct PostThreadResponse: Codable, Sendable { 12 + public let thread: ThreadViewPost 13 + } 14 + 15 + /// A post in a thread with parent/replies context 16 + /// Uses class for recursive structure support 17 + public final class ThreadViewPost: Codable, Sendable, Identifiable { 18 + public let type: String? 19 + public let post: Post 20 + public let parent: ThreadParent? 21 + public let replies: [ThreadReply]? 22 + 23 + public var id: String { post.uri ?? "" } 24 + 25 + enum CodingKeys: String, CodingKey { 26 + case type = "$type" 27 + case post, parent, replies 28 + } 29 + 30 + public init(type: String?, post: Post, parent: ThreadParent?, replies: [ThreadReply]?) { 31 + self.type = type 32 + self.post = post 33 + self.parent = parent 34 + self.replies = replies 35 + } 36 + } 37 + 38 + /// Parent of a thread post (can be another post or blocked/not found) 39 + public indirect enum ThreadParent: Codable, Sendable { 40 + case post(ThreadViewPost) 41 + case notFound(NotFoundPost) 42 + case blocked(BlockedPost) 43 + 44 + public init(from decoder: Decoder) throws { 45 + let container = try decoder.container(keyedBy: CodingKeys.self) 46 + let type = try container.decodeIfPresent(String.self, forKey: .type) ?? "" 47 + 48 + if type.contains("notFoundPost") { 49 + self = .notFound(try NotFoundPost(from: decoder)) 50 + } else if type.contains("blockedPost") { 51 + self = .blocked(try BlockedPost(from: decoder)) 52 + } else { 53 + self = .post(try ThreadViewPost(from: decoder)) 54 + } 55 + } 56 + 57 + public func encode(to encoder: Encoder) throws { 58 + switch self { 59 + case .post(let threadPost): 60 + try threadPost.encode(to: encoder) 61 + case .notFound(let notFound): 62 + try notFound.encode(to: encoder) 63 + case .blocked(let blocked): 64 + try blocked.encode(to: encoder) 65 + } 66 + } 67 + 68 + enum CodingKeys: String, CodingKey { 69 + case type = "$type" 70 + } 71 + } 72 + 73 + /// Reply to a thread post (can be another post or blocked/not found) 74 + public indirect enum ThreadReply: Codable, Sendable { 75 + case post(ThreadViewPost) 76 + case notFound(NotFoundPost) 77 + case blocked(BlockedPost) 78 + 79 + public init(from decoder: Decoder) throws { 80 + let container = try decoder.container(keyedBy: CodingKeys.self) 81 + let type = try container.decodeIfPresent(String.self, forKey: .type) ?? "" 82 + 83 + if type.contains("notFoundPost") { 84 + self = .notFound(try NotFoundPost(from: decoder)) 85 + } else if type.contains("blockedPost") { 86 + self = .blocked(try BlockedPost(from: decoder)) 87 + } else { 88 + self = .post(try ThreadViewPost(from: decoder)) 89 + } 90 + } 91 + 92 + public func encode(to encoder: Encoder) throws { 93 + switch self { 94 + case .post(let threadPost): 95 + try threadPost.encode(to: encoder) 96 + case .notFound(let notFound): 97 + try notFound.encode(to: encoder) 98 + case .blocked(let blocked): 99 + try blocked.encode(to: encoder) 100 + } 101 + } 102 + 103 + enum CodingKeys: String, CodingKey { 104 + case type = "$type" 105 + } 106 + } 107 + 108 + /// A post that was not found 109 + public struct NotFoundPost: Codable, Sendable { 110 + public let type: String 111 + public let uri: String 112 + public let notFound: Bool 113 + 114 + enum CodingKeys: String, CodingKey { 115 + case type = "$type" 116 + case uri, notFound 117 + } 118 + } 119 + 120 + /// A post that was blocked 121 + public struct BlockedPost: Codable, Sendable { 122 + public let type: String 123 + public let uri: String 124 + public let blocked: Bool 125 + public let author: BlockedAuthor 126 + 127 + enum CodingKeys: String, CodingKey { 128 + case type = "$type" 129 + case uri, blocked, author 130 + } 131 + } 132 + 133 + /// Author info for a blocked post 134 + public struct BlockedAuthor: Codable, Sendable { 135 + public let did: String 136 + public let viewer: Viewer? 137 + }
+13
Sources/bskyKit/Models/Posts.swift
··· 1 + // 2 + // Posts.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Response from app.bsky.feed.getPosts 11 + public struct Posts: Codable, Sendable { 12 + public let posts: [Post] 13 + }
+25
Sources/bskyKit/Models/Preferences.swift
··· 1 + // 2 + // Preferences.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 10/11/25. 6 + // 7 + 8 + import Foundation 9 + 10 + public struct Preferences: Codable, Sendable { 11 + public var preferences: [Preference] 12 + } 13 + 14 + public struct Preference: Codable, Sendable { 15 + public let type: String 16 + public var saved: [String] 17 + public var pinned: [String] 18 + 19 + enum CodingKeys: String, CodingKey { 20 + case type = "$type" 21 + case saved 22 + case pinned 23 + } 24 + } 25 +
+26
Sources/bskyKit/Models/Profile.swift
··· 1 + // 2 + // Profile.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 10/11/25. 6 + // 7 + 8 + import Foundation 9 + 10 + public struct Profile: Codable, Sendable, Identifiable { 11 + public let did: String 12 + public let handle: String 13 + public let displayName: String? 14 + public let description: String? 15 + public let avatar: String? 16 + public let banner: String? 17 + public let followsCount: Int? 18 + public let followersCount: Int? 19 + public let postsCount: Int? 20 + public let indexedAt: Date? 21 + public let viewer: Viewer? 22 + public let labels: [AuthorLabels]? 23 + 24 + /// Stable identifier based on DID 25 + public var id: String { did } 26 + }
+38
Sources/bskyKit/Models/SearchActors.swift
··· 1 + // 2 + // SearchActors.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Response from app.bsky.actor.searchActors 11 + public struct SearchActorsResult: Codable, Sendable { 12 + public let actors: [ActorProfile] 13 + public let cursor: String? 14 + } 15 + 16 + /// Response from app.bsky.actor.searchActorsTypeahead 17 + public struct SearchActorsTypeaheadResult: Codable, Sendable { 18 + public let actors: [ActorProfile] 19 + } 20 + 21 + /// A profile returned from actor search 22 + public struct ActorProfile: Codable, Sendable, Identifiable { 23 + public let did: String 24 + public let handle: String 25 + public let displayName: String? 26 + public let avatar: String? 27 + public let description: String? 28 + public let indexedAt: Date? 29 + public let viewer: Viewer? 30 + public let labels: [AuthorLabels]? 31 + 32 + public var id: String { did } 33 + } 34 + 35 + /// Response from app.bsky.actor.getProfiles 36 + public struct Profiles: Codable, Sendable { 37 + public let profiles: [ActorProfile] 38 + }
+370
Sources/bskyKit/Models/Timeline.swift
··· 1 + // 2 + // Timeline.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 10/11/25. 6 + // 7 + 8 + import Foundation 9 + 10 + public struct Timeline: Codable, Sendable { 11 + public var feed: [TimelineItem] 12 + public var cursor: String 13 + } 14 + 15 + public struct TimelineItem: Codable, Sendable { 16 + public let post: Post 17 + public let reply: Reply? 18 + } 19 + /* 20 + { 21 + "post": { 22 + "uri": "at://did:plc:gkqxrdozmfap5ehgd5xlhem2/app.bsky.feed.post/3l7r3os55mj2r", 23 + "cid": "bafyreign5fpvfsfj6xriqbdkp3pwf5lmcfzvkedxy6yi3csxmwfkf7cdiu", 24 + "author": { 25 + "did": "did:plc:gkqxrdozmfap5ehgd5xlhem2", 26 + "handle": "atprotesting123.bsky.social", 27 + "viewer": { 28 + "muted": false, 29 + "blockedBy": false, 30 + "following": "at://did:plc:aq5iwu4gjdcg2hq53llism3x/app.bsky.graph.follow/3l7oxdij6km2a", 31 + "followedBy": "at://did:plc:gkqxrdozmfap5ehgd5xlhem2/app.bsky.graph.follow/3kcyrue74z32v" 32 + }, 33 + "labels": [], 34 + "createdAt": "2023-10-30T21:44:11.344Z" 35 + }, 36 + "record": { 37 + "$type": "app.bsky.feed.post", 38 + "createdAt": "2024-10-30T21:30:34.509Z", 39 + "facets": [ 40 + { 41 + "features": [ 42 + { 43 + "$type": "app.bsky.richtext.facet#link", 44 + "uri": "https://x.com" 45 + } 46 + ], 47 + "index": { 48 + "byteEnd": 5, 49 + "byteStart": 0 50 + } 51 + } 52 + ], 53 + "langs": [ 54 + "en" 55 + ], 56 + "text": "x.com" 57 + }, 58 + "replyCount": 0, 59 + "repostCount": 0, 60 + "likeCount": 0, 61 + "quoteCount": 0, 62 + "indexedAt": "2024-10-30T21:30:34.509Z", 63 + "viewer": { 64 + "threadMuted": false, 65 + "embeddingDisabled": false 66 + }, 67 + "labels": [] 68 + } 69 + },*/ 70 + 71 + extension TimelineItem: Equatable { 72 + public static func == (lhs: TimelineItem, rhs: TimelineItem) -> Bool { 73 + lhs.post.uri == rhs.post.uri && lhs.post.cid == rhs.post.cid 74 + } 75 + } 76 + 77 + extension TimelineItem: Identifiable { 78 + /// Stable identifier based on post URI and CID 79 + public var id: String { 80 + "\(post.uri ?? "")-\(post.cid ?? "")" 81 + } 82 + } 83 + 84 + public struct Post: Codable, Sendable { 85 + public let uri: String? 86 + public let cid: String? 87 + public let author: Author 88 + public let record: Record 89 + public let facets: PostFacet? 90 + public let replyCount: Int 91 + public let repostCount: Int 92 + public let likeCount: Int 93 + public let indexedAt: String 94 + public let viewer: Viewer 95 + public let labels: [String] 96 + public let embed: Embed? 97 + } 98 + 99 + public struct PostFacet: Codable, Sendable { 100 + public let facets: [Facet] 101 + public let createdAt: Date 102 + } 103 + 104 + public struct Facet: Codable, Sendable { 105 + public let index: FacetIndex 106 + public let features: [FacetFeature] 107 + 108 + } 109 + 110 + public struct FacetFeature: Codable, Sendable { 111 + public let uri: String? 112 + public let type: FacetType 113 + 114 + enum CodingKeys: String, CodingKey { 115 + case uri 116 + case type = "$type" 117 + } 118 + } 119 + 120 + public enum FacetType: Codable, Sendable { 121 + case link(String) 122 + case unknown(String) 123 + 124 + public init(from decoder: Decoder) throws { 125 + let container = try decoder.singleValueContainer() 126 + let value = try container.decode(String.self) 127 + 128 + switch value { 129 + case "app.bsky.richtext.facet#link": self = .link(value) 130 + default: self = .unknown(value) 131 + } 132 + } 133 + 134 + public func encode(to encoder: Encoder) throws { 135 + var container = encoder.singleValueContainer() 136 + switch self { 137 + case .link(let value), .unknown(let value): 138 + try container.encode(value) 139 + } 140 + } 141 + } 142 + 143 + public struct FacetIndex: Codable, Sendable { 144 + public let byteEnd: Int 145 + public let byteStart: Int 146 + } 147 + 148 + public struct Embed: Codable, Sendable { 149 + public let type: String 150 + public let images: [EmbeddedMedia]? 151 + public let media: Media? 152 + public let record: EmbedRecord? 153 + public let external: EmbedExternal? 154 + 155 + enum CodingKeys: String, CodingKey { 156 + case images, media, record, external 157 + case type = "$type" 158 + } 159 + } 160 + 161 + public struct EmbedExternal: Codable, Sendable { 162 + public let uri: String? 163 + public let thumb: TimelineImage? 164 + public let title: String 165 + public let externalDescription: String 166 + 167 + enum CodingKeys: String, CodingKey { 168 + case uri, thumb, title 169 + case externalDescription = "description" 170 + } 171 + } 172 + 173 + public struct EmbedRecord: Codable, Sendable { 174 + public let type: String? 175 + public let record: UnpopulatedPost? 176 + public let uri: String? 177 + public let cid: String? 178 + public let author: Author? 179 + public let value: EmbedRecordValue? 180 + // public let labels: [String] 181 + // public let indexedAt: Date 182 + // public let embeds: [String] // TODO: This isn't correct 183 + 184 + 185 + enum CodingKeys: String, CodingKey { 186 + case type = "$type" 187 + case record, uri, cid, author, value/*, labels, indexedAt, embeds*/ 188 + } 189 + } 190 + 191 + public struct EmbedRecordValue: Codable, Sendable { 192 + public let text: String 193 + public let type: String 194 + public let langs: [String]? 195 + public let reply: ReplyDetail? 196 + public let createdAt: String 197 + 198 + enum CodingKeys: String, CodingKey { 199 + case type = "$type" 200 + case langs, reply, createdAt, text 201 + } 202 + } 203 + 204 + public struct Media: Codable, Sendable { 205 + public let type: String 206 + public let images: [EmbeddedMedia]? 207 + 208 + enum CodingKeys: String, CodingKey { 209 + case type = "$type" 210 + case images 211 + } 212 + } 213 + 214 + public enum EmbedType: String, Codable, Sendable { 215 + case image = "app.bsky.embed.images" 216 + case recordWithMedia = "app.bsky.embed.recordWithMedia" 217 + case external = "app.bsky.embed.external" 218 + case record = "app.bsky.embed.record" 219 + } 220 + 221 + public enum TimelineImage: Codable, Sendable, Identifiable { 222 + case string(String) 223 + case image(EmbeddedImage) 224 + 225 + public init(from decoder: Decoder) throws { 226 + let container = try decoder.singleValueContainer() 227 + 228 + if let string = try? container.decode(String.self) { 229 + self = .string(string) 230 + return 231 + } 232 + 233 + if let image = try? container.decode(EmbeddedImage.self) { 234 + self = .image(image) 235 + return 236 + } 237 + 238 + throw DecodingError.typeMismatch(TimelineImage.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for MyProperty")) 239 + } 240 + 241 + public func encode(to encoder: Encoder) throws { 242 + var container = encoder.singleValueContainer() 243 + switch self { 244 + case .string(let string): 245 + try container.encode(string) 246 + case .image(let image): 247 + try container.encode(image) 248 + } 249 + } 250 + 251 + /// Stable identifier based on content 252 + public var id: String { 253 + switch self { 254 + case .string(let value): 255 + return value 256 + case .image(let img): 257 + return "\(img.type)-\(img.size)" 258 + } 259 + } 260 + } 261 + 262 + public struct EmbeddedMedia: Codable, Sendable { 263 + public let thumb: TimelineImage? 264 + public let fullsize: String? 265 + public let alt: String 266 + public let aspectRatio: EmbedImageAspectRatio? 267 + public let image: TimelineImage? 268 + } 269 + 270 + public struct EmbeddedImage: Codable, Sendable { 271 + public let type: String 272 + public let ref: [String : String] 273 + public let mimeType: String 274 + public let size: Int 275 + 276 + enum CodingKeys: String, CodingKey { 277 + case type = "$type" 278 + case ref, mimeType, size 279 + } 280 + } 281 + 282 + public struct EmbedImageAspectRatio: Codable, Sendable { 283 + public let width: Int 284 + public let height: Int 285 + } 286 + 287 + public struct Reply: Codable, Sendable { 288 + public let root: Root 289 + public let parent: Parent 290 + } 291 + 292 + public struct Author: Codable, Sendable { 293 + public let did: String 294 + public let handle: String 295 + public let displayName: String? 296 + public let avatar: String? 297 + public let viewer: Viewer 298 + public let labels: [AuthorLabels] 299 + } 300 + 301 + public struct AuthorLabels: Codable, Sendable { 302 + public let src: String 303 + public let uri: String? 304 + public let cid: String? 305 + public let val: String 306 + public let cts: String 307 + } 308 + 309 + public struct Record: Codable, Sendable { 310 + public let text: String 311 + public let type: String 312 + public let langs: [String]? 313 + public let reply: ReplyDetail? 314 + public let createdAt: String 315 + public let embed: Embed? 316 + public let facets: [Facet]? 317 + 318 + enum CodingKeys: String, CodingKey { 319 + case type = "$type" 320 + case langs, reply, createdAt, embed, text, facets 321 + } 322 + } 323 + 324 + public struct ReplyDetail: Codable, Sendable { 325 + public let root: UnpopulatedPost 326 + public let parent: UnpopulatedPost 327 + } 328 + 329 + public struct UnpopulatedPost: Codable, Sendable { 330 + public let cid: String? 331 + public let uri: String? 332 + } 333 + 334 + public struct Root: Codable, Sendable { 335 + public let type: String 336 + public let uri: String? 337 + public let cid: String? 338 + public let author: Author 339 + public let record: Record 340 + public let replyCount: Int 341 + public let repostCount: Int 342 + public let likeCount: Int 343 + public let indexedAt: String 344 + public let viewer: Viewer 345 + public let labels: [String] 346 + 347 + enum CodingKeys: String, CodingKey { 348 + case type = "$type" 349 + case uri, cid, author, record, replyCount, repostCount, likeCount, indexedAt, viewer, labels 350 + } 351 + } 352 + 353 + public struct Parent: Codable, Sendable { 354 + public let type: String 355 + public let uri: String? 356 + public let cid: String? 357 + public let author: Author 358 + public let record: Record 359 + public let replyCount: Int 360 + public let repostCount: Int 361 + public let likeCount: Int 362 + public let indexedAt: String 363 + public let viewer: Viewer 364 + public let labels: [String] 365 + 366 + enum CodingKeys: String, CodingKey { 367 + case type = "$type" 368 + case uri, cid, author, record, replyCount, repostCount, likeCount, indexedAt, viewer, labels 369 + } 370 + }
+34
Sources/bskyKit/Models/Viewer.swift
··· 1 + // 2 + // Viewer.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 10/11/25. 6 + // 7 + 8 + public struct Viewer: Codable, Sendable { 9 + public let muted: Bool? 10 + public let blockedBy: Bool? 11 + public let following: String? 12 + public let followedBy: String? 13 + public let blocking: String? 14 + public let mutedByList: String? 15 + public let blockingByList: String? 16 + 17 + public init( 18 + muted: Bool? = nil, 19 + blockedBy: Bool? = nil, 20 + following: String? = nil, 21 + followedBy: String? = nil, 22 + blocking: String? = nil, 23 + mutedByList: String? = nil, 24 + blockingByList: String? = nil 25 + ) { 26 + self.muted = muted 27 + self.blockedBy = blockedBy 28 + self.following = following 29 + self.followedBy = followedBy 30 + self.blocking = blocking 31 + self.mutedByList = mutedByList 32 + self.blockingByList = blockingByList 33 + } 34 + }
+31
Sources/bskyKit/OAuth/BskyOAuth.swift
··· 1 + import Foundation 2 + @_exported import CoreATProtocol 3 + 4 + // MARK: - Re-export OAuth types from CoreATProtocol 5 + 6 + /// Re-export ATProtoOAuth as BskyOAuth for Bluesky-specific usage 7 + public typealias BskyOAuth = ATProtoOAuth 8 + 9 + /// Re-export OAuth configuration 10 + public typealias BskyOAuthConfig = ATProtoOAuthConfig 11 + 12 + /// Re-export OAuth storage 13 + public typealias BskyAuthStorage = ATProtoAuthStorage 14 + 15 + /// Re-export OAuth result 16 + public typealias BskyAuthResult = ATProtoAuthResult 17 + 18 + /// Re-export OAuth errors 19 + public typealias BskyOAuthError = ATProtoOAuthError 20 + 21 + /// Re-export identity errors 22 + public typealias BskyIdentityError = IdentityError 23 + 24 + /// Re-export user authenticator type 25 + public typealias BskyUserAuthenticator = UserAuthenticator 26 + 27 + // MARK: - Re-export OAuthenticator types (already re-exported from CoreATProtocol) 28 + // Login, Token, and LoginStorage are already available via CoreATProtocol 29 + 30 + // MARK: - Re-export ErrorMessage 31 + public typealias BskyErrorMessage = ErrorMessage
+69
Sources/bskyKit/RepoAPI.swift
··· 1 + // 2 + // RepoAPI.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + import CoreATProtocol 10 + 11 + /// API endpoints for com.atproto.repo.* lexicons 12 + enum RepoAPI: Sendable { 13 + case createRecord(body: Data) 14 + case deleteRecord(body: Data) 15 + case getRecord(repo: String, collection: String, rkey: String) 16 + case listRecords(repo: String, collection: String, limit: Int, cursor: String?) 17 + // Note: uploadBlob requires CoreATProtocol updates - deferred 18 + } 19 + 20 + extension RepoAPI: EndpointType { 21 + public var baseURL: URL { 22 + get async { 23 + guard let host = await APEnvironment.current.host else { fatalError("Host not set.") } 24 + guard let url = URL(string: host) else { fatalError("RepoAPI baseURL not configured.") } 25 + return url 26 + } 27 + } 28 + 29 + var path: String { 30 + switch self { 31 + case .createRecord: "/xrpc/com.atproto.repo.createRecord" 32 + case .deleteRecord: "/xrpc/com.atproto.repo.deleteRecord" 33 + case .getRecord: "/xrpc/com.atproto.repo.getRecord" 34 + case .listRecords: "/xrpc/com.atproto.repo.listRecords" 35 + } 36 + } 37 + 38 + var httpMethod: HTTPMethod { 39 + switch self { 40 + case .createRecord, .deleteRecord: 41 + return .post 42 + case .getRecord, .listRecords: 43 + return .get 44 + } 45 + } 46 + 47 + var task: HTTPTask { 48 + switch self { 49 + case .createRecord(let body), .deleteRecord(let body): 50 + return .requestParameters(encoding: .jsonDataEncoding(data: body)) 51 + 52 + case .getRecord(let repo, let collection, let rkey): 53 + return .requestParameters(encoding: .urlEncoding(parameters: [ 54 + "repo": repo, 55 + "collection": collection, 56 + "rkey": rkey 57 + ])) 58 + 59 + case .listRecords(let repo, let collection, let limit, let cursor): 60 + var params: Parameters = ["repo": repo, "collection": collection, "limit": limit] 61 + if let cursor { params["cursor"] = cursor } 62 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 63 + } 64 + } 65 + 66 + var headers: HTTPHeaders? { 67 + nil 68 + } 69 + }
+471
Sources/bskyKit/RepoService.swift
··· 1 + // 2 + // RepoService.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + import CoreATProtocol 10 + 11 + /// Service for repository operations (create, delete, update records) 12 + @APActor 13 + public struct RepoService: Sendable { 14 + private let router: NetworkRouter<RepoAPI> = { 15 + let router = NetworkRouter<RepoAPI>(decoder: .atDecoder) 16 + router.delegate = APEnvironment.current.routerDelegate 17 + return router 18 + }() 19 + 20 + public init() {} 21 + 22 + // MARK: - Record Operations 23 + 24 + /// Creates a new record in the repository 25 + public func createRecord( 26 + repo: String, 27 + collection: String, 28 + record: [String: Any], 29 + rkey: String? = nil 30 + ) async throws -> CreateRecordResponse { 31 + var body: [String: Any] = [ 32 + "repo": repo, 33 + "collection": collection, 34 + "record": record 35 + ] 36 + if let rkey { body["rkey"] = rkey } 37 + 38 + let data = try JSONSerialization.data(withJSONObject: body) 39 + return try await router.execute(.createRecord(body: data)) 40 + } 41 + 42 + /// Deletes a record from the repository 43 + public func deleteRecord( 44 + repo: String, 45 + collection: String, 46 + rkey: String 47 + ) async throws { 48 + let body: [String: Any] = [ 49 + "repo": repo, 50 + "collection": collection, 51 + "rkey": rkey 52 + ] 53 + let data = try JSONSerialization.data(withJSONObject: body) 54 + let _: EmptyResponse = try await router.execute(.deleteRecord(body: data)) 55 + } 56 + 57 + /// Gets a single record 58 + public func getRecord( 59 + repo: String, 60 + collection: String, 61 + rkey: String 62 + ) async throws -> GetRecordResponse { 63 + try await router.execute(.getRecord(repo: repo, collection: collection, rkey: rkey)) 64 + } 65 + 66 + /// Lists records in a collection 67 + public func listRecords( 68 + repo: String, 69 + collection: String, 70 + limit: Int = 50, 71 + cursor: String? = nil 72 + ) async throws -> ListRecordsResponse { 73 + try await router.execute(.listRecords(repo: repo, collection: collection, limit: limit, cursor: cursor)) 74 + } 75 + 76 + // Note: uploadBlob deferred until CoreATProtocol is updated 77 + 78 + // MARK: - High-Level Operations 79 + 80 + /// Creates a new post 81 + public func createPost(_ post: PostRecord, repo: String) async throws -> CreateRecordResponse { 82 + try await createRecord( 83 + repo: repo, 84 + collection: "app.bsky.feed.post", 85 + record: post.toRecord() 86 + ) 87 + } 88 + 89 + /// Likes a post 90 + public func like(uri: String, cid: String, repo: String) async throws -> CreateRecordResponse { 91 + let record: [String: Any] = [ 92 + "$type": "app.bsky.feed.like", 93 + "subject": ["uri": uri, "cid": cid], 94 + "createdAt": ISO8601DateFormatter().string(from: Date()) 95 + ] 96 + return try await createRecord(repo: repo, collection: "app.bsky.feed.like", record: record) 97 + } 98 + 99 + /// Removes a like 100 + public func unlike(uri: String, repo: String) async throws { 101 + guard let rkey = extractRkey(from: uri) else { 102 + throw RepoError.invalidUri(uri) 103 + } 104 + try await deleteRecord(repo: repo, collection: "app.bsky.feed.like", rkey: rkey) 105 + } 106 + 107 + /// Reposts a post 108 + public func repost(uri: String, cid: String, repo: String) async throws -> CreateRecordResponse { 109 + let record: [String: Any] = [ 110 + "$type": "app.bsky.feed.repost", 111 + "subject": ["uri": uri, "cid": cid], 112 + "createdAt": ISO8601DateFormatter().string(from: Date()) 113 + ] 114 + return try await createRecord(repo: repo, collection: "app.bsky.feed.repost", record: record) 115 + } 116 + 117 + /// Removes a repost 118 + public func unrepost(uri: String, repo: String) async throws { 119 + guard let rkey = extractRkey(from: uri) else { 120 + throw RepoError.invalidUri(uri) 121 + } 122 + try await deleteRecord(repo: repo, collection: "app.bsky.feed.repost", rkey: rkey) 123 + } 124 + 125 + /// Follows a user 126 + public func follow(did: String, repo: String) async throws -> CreateRecordResponse { 127 + let record: [String: Any] = [ 128 + "$type": "app.bsky.graph.follow", 129 + "subject": did, 130 + "createdAt": ISO8601DateFormatter().string(from: Date()) 131 + ] 132 + return try await createRecord(repo: repo, collection: "app.bsky.graph.follow", record: record) 133 + } 134 + 135 + /// Unfollows a user 136 + public func unfollow(uri: String, repo: String) async throws { 137 + guard let rkey = extractRkey(from: uri) else { 138 + throw RepoError.invalidUri(uri) 139 + } 140 + try await deleteRecord(repo: repo, collection: "app.bsky.graph.follow", rkey: rkey) 141 + } 142 + 143 + /// Blocks a user 144 + public func block(did: String, repo: String) async throws -> CreateRecordResponse { 145 + let record: [String: Any] = [ 146 + "$type": "app.bsky.graph.block", 147 + "subject": did, 148 + "createdAt": ISO8601DateFormatter().string(from: Date()) 149 + ] 150 + return try await createRecord(repo: repo, collection: "app.bsky.graph.block", record: record) 151 + } 152 + 153 + /// Unblocks a user 154 + public func unblock(uri: String, repo: String) async throws { 155 + guard let rkey = extractRkey(from: uri) else { 156 + throw RepoError.invalidUri(uri) 157 + } 158 + try await deleteRecord(repo: repo, collection: "app.bsky.graph.block", rkey: rkey) 159 + } 160 + 161 + // MARK: - Private Helpers 162 + 163 + private func extractRkey(from uri: String) -> String? { 164 + // AT URI format: at://did:plc:xxx/collection/rkey 165 + uri.split(separator: "/").last.map(String.init) 166 + } 167 + } 168 + 169 + // MARK: - Response Types 170 + 171 + public struct CreateRecordResponse: Codable, Sendable { 172 + public let uri: String 173 + public let cid: String 174 + } 175 + 176 + public struct GetRecordResponse: Codable, Sendable { 177 + public let uri: String 178 + public let cid: String? 179 + public let value: RecordValue 180 + } 181 + 182 + public struct RecordValue: Codable, Sendable { 183 + public let type: String? 184 + public let text: String? 185 + public let createdAt: String? 186 + 187 + enum CodingKeys: String, CodingKey { 188 + case type = "$type" 189 + case text, createdAt 190 + } 191 + } 192 + 193 + public struct ListRecordsResponse: Codable, Sendable { 194 + public let records: [RecordItem] 195 + public let cursor: String? 196 + } 197 + 198 + public struct RecordItem: Codable, Sendable { 199 + public let uri: String 200 + public let cid: String 201 + public let value: RecordValue 202 + } 203 + 204 + public struct BlobResponse: Codable, Sendable { 205 + public let blob: BlobRef 206 + } 207 + 208 + public struct BlobRef: Codable, Sendable { 209 + public let type: String 210 + public let ref: BlobLink 211 + public let mimeType: String 212 + public let size: Int 213 + 214 + enum CodingKeys: String, CodingKey { 215 + case type = "$type" 216 + case ref, mimeType, size 217 + } 218 + } 219 + 220 + public struct BlobLink: Codable, Sendable { 221 + public let link: String 222 + 223 + enum CodingKeys: String, CodingKey { 224 + case link = "$link" 225 + } 226 + } 227 + 228 + // MARK: - Post Record 229 + 230 + /// A record for creating a post 231 + public struct PostRecord: Sendable { 232 + public let text: String 233 + public let facets: [RichTextFacet]? 234 + public let reply: ReplyRef? 235 + public let embed: PostEmbed? 236 + public let langs: [String]? 237 + public let createdAt: Date 238 + 239 + public init( 240 + text: String, 241 + facets: [RichTextFacet]? = nil, 242 + reply: ReplyRef? = nil, 243 + embed: PostEmbed? = nil, 244 + langs: [String]? = nil, 245 + createdAt: Date = Date() 246 + ) { 247 + self.text = text 248 + self.facets = facets 249 + self.reply = reply 250 + self.embed = embed 251 + self.langs = langs 252 + self.createdAt = createdAt 253 + } 254 + 255 + /// Creates a PostRecord with auto-detected facets 256 + public static func create(text: String, reply: ReplyRef? = nil, embed: PostEmbed? = nil, langs: [String]? = nil) -> PostRecord { 257 + let richText = RichText.detect(in: text) 258 + return PostRecord( 259 + text: text, 260 + facets: richText.facets.isEmpty ? nil : richText.facets, 261 + reply: reply, 262 + embed: embed, 263 + langs: langs 264 + ) 265 + } 266 + 267 + func toRecord() -> [String: Any] { 268 + let formatter = ISO8601DateFormatter() 269 + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 270 + 271 + var record: [String: Any] = [ 272 + "$type": "app.bsky.feed.post", 273 + "text": text, 274 + "createdAt": formatter.string(from: createdAt) 275 + ] 276 + 277 + if let facets, !facets.isEmpty { 278 + record["facets"] = facets.map { facet in 279 + var dict: [String: Any] = [ 280 + "index": [ 281 + "byteStart": facet.index.byteStart, 282 + "byteEnd": facet.index.byteEnd 283 + ] 284 + ] 285 + dict["features"] = facet.features.map { feature -> [String: Any] in 286 + switch feature { 287 + case .link(let link): 288 + return ["$type": "app.bsky.richtext.facet#link", "uri": link.uri] 289 + case .mention(let mention): 290 + return ["$type": "app.bsky.richtext.facet#mention", "did": mention.did ?? ""] 291 + case .tag(let tag): 292 + return ["$type": "app.bsky.richtext.facet#tag", "tag": tag.tag] 293 + } 294 + } 295 + return dict 296 + } 297 + } 298 + 299 + if let reply { 300 + record["reply"] = [ 301 + "root": ["uri": reply.root.uri, "cid": reply.root.cid], 302 + "parent": ["uri": reply.parent.uri, "cid": reply.parent.cid] 303 + ] 304 + } 305 + 306 + if let embed { 307 + record["embed"] = embed.toRecord() 308 + } 309 + 310 + if let langs { 311 + record["langs"] = langs 312 + } 313 + 314 + return record 315 + } 316 + } 317 + 318 + /// Reference to a post for replies 319 + public struct ReplyRef: Sendable { 320 + public let root: PostRef 321 + public let parent: PostRef 322 + 323 + public init(root: PostRef, parent: PostRef) { 324 + self.root = root 325 + self.parent = parent 326 + } 327 + } 328 + 329 + /// Reference to a post (URI + CID) 330 + public struct PostRef: Sendable { 331 + public let uri: String 332 + public let cid: String 333 + 334 + public init(uri: String, cid: String) { 335 + self.uri = uri 336 + self.cid = cid 337 + } 338 + } 339 + 340 + /// Embed types for posts 341 + public enum PostEmbed: Sendable { 342 + case images([ImageEmbed]) 343 + case external(ExternalEmbed) 344 + case record(RecordEmbed) 345 + case recordWithMedia(RecordEmbed, [ImageEmbed]) 346 + 347 + func toRecord() -> [String: Any] { 348 + switch self { 349 + case .images(let images): 350 + return [ 351 + "$type": "app.bsky.embed.images", 352 + "images": images.map { $0.toRecord() } 353 + ] 354 + case .external(let external): 355 + return [ 356 + "$type": "app.bsky.embed.external", 357 + "external": external.toRecord() 358 + ] 359 + case .record(let record): 360 + return [ 361 + "$type": "app.bsky.embed.record", 362 + "record": ["uri": record.uri, "cid": record.cid] 363 + ] 364 + case .recordWithMedia(let record, let images): 365 + return [ 366 + "$type": "app.bsky.embed.recordWithMedia", 367 + "record": ["record": ["uri": record.uri, "cid": record.cid]], 368 + "media": [ 369 + "$type": "app.bsky.embed.images", 370 + "images": images.map { $0.toRecord() } 371 + ] 372 + ] 373 + } 374 + } 375 + } 376 + 377 + /// Image embed 378 + public struct ImageEmbed: Sendable { 379 + public let image: BlobRef 380 + public let alt: String 381 + public let aspectRatio: AspectRatio? 382 + 383 + public init(image: BlobRef, alt: String, aspectRatio: AspectRatio? = nil) { 384 + self.image = image 385 + self.alt = alt 386 + self.aspectRatio = aspectRatio 387 + } 388 + 389 + func toRecord() -> [String: Any] { 390 + var record: [String: Any] = [ 391 + "image": [ 392 + "$type": image.type, 393 + "ref": ["$link": image.ref.link], 394 + "mimeType": image.mimeType, 395 + "size": image.size 396 + ], 397 + "alt": alt 398 + ] 399 + if let aspectRatio { 400 + record["aspectRatio"] = ["width": aspectRatio.width, "height": aspectRatio.height] 401 + } 402 + return record 403 + } 404 + } 405 + 406 + /// Aspect ratio for images 407 + public struct AspectRatio: Sendable { 408 + public let width: Int 409 + public let height: Int 410 + 411 + public init(width: Int, height: Int) { 412 + self.width = width 413 + self.height = height 414 + } 415 + } 416 + 417 + /// External link embed 418 + public struct ExternalEmbed: Sendable { 419 + public let uri: String 420 + public let title: String 421 + public let description: String 422 + public let thumb: BlobRef? 423 + 424 + public init(uri: String, title: String, description: String, thumb: BlobRef? = nil) { 425 + self.uri = uri 426 + self.title = title 427 + self.description = description 428 + self.thumb = thumb 429 + } 430 + 431 + func toRecord() -> [String: Any] { 432 + var record: [String: Any] = [ 433 + "uri": uri, 434 + "title": title, 435 + "description": description 436 + ] 437 + if let thumb { 438 + record["thumb"] = [ 439 + "$type": thumb.type, 440 + "ref": ["$link": thumb.ref.link], 441 + "mimeType": thumb.mimeType, 442 + "size": thumb.size 443 + ] 444 + } 445 + return record 446 + } 447 + } 448 + 449 + /// Record embed (quote post) 450 + public struct RecordEmbed: Sendable { 451 + public let uri: String 452 + public let cid: String 453 + 454 + public init(uri: String, cid: String) { 455 + self.uri = uri 456 + self.cid = cid 457 + } 458 + } 459 + 460 + // MARK: - Errors 461 + 462 + public enum RepoError: Error, LocalizedError { 463 + case invalidUri(String) 464 + 465 + public var errorDescription: String? { 466 + switch self { 467 + case .invalidUri(let uri): 468 + return "Invalid AT URI: \(uri)" 469 + } 470 + } 471 + }
+407
Sources/bskyKit/RichText/RichText.swift
··· 1 + // 2 + // RichText.swift 3 + // bskyKit 4 + // 5 + // Created by Thomas Rademaker on 01/02/2026. 6 + // 7 + 8 + import Foundation 9 + 10 + /// Handles rich text with facets for AT Protocol. 11 + /// 12 + /// `RichText` provides utilities for creating and parsing rich text content 13 + /// that includes mentions, links, and hashtags. It handles the critical conversion 14 + /// between character indices and byte indices required by the AT Protocol. 15 + /// 16 + /// ## Overview 17 + /// 18 + /// Bluesky uses "facets" to mark up rich text. Each facet identifies a span of text 19 + /// using **byte indices** (not character indices) and associates it with a feature 20 + /// type like mention, link, or hashtag. 21 + /// 22 + /// ## Auto-Detection 23 + /// 24 + /// The easiest way to create rich text is with automatic detection: 25 + /// 26 + /// ```swift 27 + /// let text = "Hey @alice.bsky.social check https://example.com #atproto" 28 + /// let richText = RichText.detect(in: text) 29 + /// 30 + /// print("Found \(richText.facets.count) facets") 31 + /// ``` 32 + /// 33 + /// ## Manual Creation 34 + /// 35 + /// For precise control, create facets manually: 36 + /// 37 + /// ```swift 38 + /// let facet = RichTextFacet( 39 + /// index: RichTextFacetIndex(byteStart: 0, byteEnd: 10), 40 + /// features: [.link(RichTextLink(uri: "https://example.com"))] 41 + /// ) 42 + /// let richText = RichText(text: "Visit here", facets: [facet]) 43 + /// ``` 44 + /// 45 + /// ## Byte Index Handling 46 + /// 47 + /// Always use ``byteIndex(from:)`` when converting from String indices: 48 + /// 49 + /// ```swift 50 + /// let text = "Hello 👋" // Emoji takes 4 bytes 51 + /// let richText = RichText(text: text) 52 + /// let byteIdx = richText.byteIndex(from: text.endIndex) // Returns 10, not 7 53 + /// ``` 54 + /// 55 + /// ## Topics 56 + /// 57 + /// ### Creating Rich Text 58 + /// - ``init(text:facets:)`` 59 + /// - ``detect(in:)`` 60 + /// - ``detectFacets()`` 61 + /// 62 + /// ### Converting Indices 63 + /// - ``byteIndex(from:)`` 64 + /// - ``characterIndex(from:)`` 65 + /// 66 + /// ### API Conversion 67 + /// - ``toAPIFacets()`` 68 + public struct RichText: Sendable { 69 + /// The plain text content. 70 + public let text: String 71 + 72 + /// Detected facets marking mentions, links, and hashtags. 73 + public private(set) var facets: [RichTextFacet] 74 + 75 + /// Creates a RichText with the given text and optional facets. 76 + /// - Parameters: 77 + /// - text: The plain text content. 78 + /// - facets: Pre-computed facets (default: empty). 79 + public init(text: String, facets: [RichTextFacet] = []) { 80 + self.text = text 81 + self.facets = facets 82 + } 83 + 84 + /// Creates RichText with auto-detected mentions, links, and hashtags. 85 + /// 86 + /// This is the recommended way to create rich text content: 87 + /// 88 + /// ```swift 89 + /// let richText = RichText.detect(in: "Hey @alice check https://example.com #cool") 90 + /// // richText.facets contains 3 facets 91 + /// ``` 92 + /// 93 + /// - Parameter text: The text to analyze for facets. 94 + /// - Returns: A RichText instance with detected facets. 95 + public static func detect(in text: String) -> RichText { 96 + var richText = RichText(text: text) 97 + richText.detectFacets() 98 + return richText 99 + } 100 + 101 + /// Detects and populates facets for links, mentions, and hashtags. 102 + /// 103 + /// Called automatically by ``detect(in:)``. Call manually if you need 104 + /// to re-detect facets after modifying the text. 105 + public mutating func detectFacets() { 106 + facets = [] 107 + detectLinks() 108 + detectMentions() 109 + detectHashtags() 110 + facets.sort { $0.index.byteStart < $1.index.byteStart } 111 + } 112 + 113 + /// Converts a Swift String.Index to a UTF-8 byte index. 114 + /// 115 + /// Use this when you have a character position and need the byte offset 116 + /// for creating facets. 117 + /// 118 + /// ```swift 119 + /// let text = "Hi 👋" 120 + /// let richText = RichText(text: text) 121 + /// let byteIdx = richText.byteIndex(from: text.endIndex) // 7 (not 4) 122 + /// ``` 123 + /// 124 + /// - Parameter characterIndex: A String.Index in the text. 125 + /// - Returns: The byte offset in UTF-8 encoding. 126 + public func byteIndex(from characterIndex: String.Index) -> Int { 127 + text.utf8.distance(from: text.startIndex, to: characterIndex) 128 + } 129 + 130 + /// Converts a UTF-8 byte index to a Swift String.Index. 131 + /// 132 + /// Use this when you have a byte offset from a facet and need to 133 + /// extract the corresponding substring. 134 + /// 135 + /// - Parameter byteIndex: The byte offset in UTF-8 encoding. 136 + /// - Returns: The corresponding String.Index, or nil if invalid. 137 + public func characterIndex(from byteIndex: Int) -> String.Index? { 138 + var currentByte = 0 139 + for index in text.indices { 140 + if currentByte == byteIndex { 141 + return index 142 + } 143 + let char = text[index] 144 + currentByte += char.utf8.count 145 + } 146 + return currentByte == byteIndex ? text.endIndex : nil 147 + } 148 + 149 + // MARK: - Private Detection Methods 150 + 151 + private mutating func detectLinks() { 152 + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) 153 + let range = NSRange(text.startIndex..., in: text) 154 + 155 + detector?.enumerateMatches(in: text, options: [], range: range) { result, _, _ in 156 + guard let result = result, 157 + let range = Range(result.range, in: text), 158 + let url = result.url else { return } 159 + 160 + let byteStart = byteIndex(from: range.lowerBound) 161 + let byteEnd = byteIndex(from: range.upperBound) 162 + 163 + let facet = RichTextFacet( 164 + index: RichTextFacetIndex(byteStart: byteStart, byteEnd: byteEnd), 165 + features: [.link(RichTextLink(uri: url.absoluteString))] 166 + ) 167 + facets.append(facet) 168 + } 169 + } 170 + 171 + private mutating func detectMentions() { 172 + // Match @handle pattern (alphanumeric, dots, hyphens, underscores) 173 + // Handle format: @username.bsky.social or @did:plc:xxx 174 + let pattern = #"@([a-zA-Z0-9]([a-zA-Z0-9._-])*[a-zA-Z0-9]|[a-zA-Z0-9])"# 175 + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return } 176 + 177 + let range = NSRange(text.startIndex..., in: text) 178 + regex.enumerateMatches(in: text, options: [], range: range) { result, _, _ in 179 + guard let result = result, 180 + let range = Range(result.range, in: text) else { return } 181 + 182 + let handle = String(text[range].dropFirst()) // Remove @ 183 + let byteStart = byteIndex(from: range.lowerBound) 184 + let byteEnd = byteIndex(from: range.upperBound) 185 + 186 + let facet = RichTextFacet( 187 + index: RichTextFacetIndex(byteStart: byteStart, byteEnd: byteEnd), 188 + features: [.mention(RichTextMention(handle: handle))] 189 + ) 190 + facets.append(facet) 191 + } 192 + } 193 + 194 + private mutating func detectHashtags() { 195 + // Match #hashtag pattern (alphanumeric and underscores, no leading numbers) 196 + let pattern = #"#([a-zA-Z_][a-zA-Z0-9_]*)"# 197 + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return } 198 + 199 + let range = NSRange(text.startIndex..., in: text) 200 + regex.enumerateMatches(in: text, options: [], range: range) { result, _, _ in 201 + guard let result = result, 202 + let range = Range(result.range, in: text) else { return } 203 + 204 + let tag = String(text[range].dropFirst()) // Remove # 205 + let byteStart = byteIndex(from: range.lowerBound) 206 + let byteEnd = byteIndex(from: range.upperBound) 207 + 208 + let facet = RichTextFacet( 209 + index: RichTextFacetIndex(byteStart: byteStart, byteEnd: byteEnd), 210 + features: [.tag(RichTextTag(tag: tag))] 211 + ) 212 + facets.append(facet) 213 + } 214 + } 215 + } 216 + 217 + // MARK: - Facet Types 218 + 219 + /// A facet marking a segment of rich text with special meaning. 220 + /// 221 + /// Facets identify spans of text that should be rendered as interactive 222 + /// elements like mentions, links, or hashtags. 223 + /// 224 + /// ## Example 225 + /// 226 + /// ```swift 227 + /// let facet = RichTextFacet( 228 + /// index: RichTextFacetIndex(byteStart: 0, byteEnd: 19), 229 + /// features: [.mention(RichTextMention(handle: "alice.bsky.social"))] 230 + /// ) 231 + /// ``` 232 + public struct RichTextFacet: Codable, Sendable { 233 + /// The byte range of this facet in the text. 234 + public let index: RichTextFacetIndex 235 + 236 + /// The features (link, mention, tag) for this facet. 237 + public let features: [RichTextFeature] 238 + 239 + /// Creates a facet with the given index and features. 240 + public init(index: RichTextFacetIndex, features: [RichTextFeature]) { 241 + self.index = index 242 + self.features = features 243 + } 244 + } 245 + 246 + /// Byte indices marking the start and end of a facet. 247 + /// 248 + /// Indices are UTF-8 byte offsets, not character counts. 249 + /// Use ``RichText/byteIndex(from:)`` to convert from String indices. 250 + public struct RichTextFacetIndex: Codable, Sendable { 251 + /// The starting byte offset (inclusive). 252 + public let byteStart: Int 253 + 254 + /// The ending byte offset (exclusive). 255 + public let byteEnd: Int 256 + 257 + /// Creates an index with the given byte range. 258 + public init(byteStart: Int, byteEnd: Int) { 259 + self.byteStart = byteStart 260 + self.byteEnd = byteEnd 261 + } 262 + } 263 + 264 + /// A feature type within a facet. 265 + /// 266 + /// Each facet can have one or more features identifying what kind 267 + /// of rich content it represents. 268 + public enum RichTextFeature: Codable, Sendable { 269 + /// A clickable link to a URL. 270 + case link(RichTextLink) 271 + 272 + /// A mention of another user. 273 + case mention(RichTextMention) 274 + 275 + /// A hashtag for discovery. 276 + case tag(RichTextTag) 277 + 278 + enum CodingKeys: String, CodingKey { 279 + case type = "$type" 280 + case uri 281 + case did 282 + case tag 283 + } 284 + 285 + public init(from decoder: Decoder) throws { 286 + let container = try decoder.container(keyedBy: CodingKeys.self) 287 + let type = try container.decode(String.self, forKey: .type) 288 + 289 + switch type { 290 + case "app.bsky.richtext.facet#link": 291 + let uri = try container.decode(String.self, forKey: .uri) 292 + self = .link(RichTextLink(uri: uri)) 293 + case "app.bsky.richtext.facet#mention": 294 + let did = try container.decode(String.self, forKey: .did) 295 + self = .mention(RichTextMention(did: did)) 296 + case "app.bsky.richtext.facet#tag": 297 + let tag = try container.decode(String.self, forKey: .tag) 298 + self = .tag(RichTextTag(tag: tag)) 299 + default: 300 + throw DecodingError.dataCorrupted( 301 + DecodingError.Context( 302 + codingPath: decoder.codingPath, 303 + debugDescription: "Unknown facet type: \(type)" 304 + ) 305 + ) 306 + } 307 + } 308 + 309 + public func encode(to encoder: Encoder) throws { 310 + var container = encoder.container(keyedBy: CodingKeys.self) 311 + switch self { 312 + case .link(let link): 313 + try container.encode("app.bsky.richtext.facet#link", forKey: .type) 314 + try container.encode(link.uri, forKey: .uri) 315 + case .mention(let mention): 316 + try container.encode("app.bsky.richtext.facet#mention", forKey: .type) 317 + try container.encode(mention.did ?? mention.handle, forKey: .did) 318 + case .tag(let tag): 319 + try container.encode("app.bsky.richtext.facet#tag", forKey: .type) 320 + try container.encode(tag.tag, forKey: .tag) 321 + } 322 + } 323 + } 324 + 325 + /// A link facet feature representing a clickable URL. 326 + /// 327 + /// When parsed from API responses, contains the full URI. 328 + /// When creating new posts, provide the destination URL. 329 + public struct RichTextLink: Codable, Sendable { 330 + /// The destination URL. 331 + public let uri: String 332 + 333 + /// Creates a link feature with the given URI. 334 + public init(uri: String) { 335 + self.uri = uri 336 + } 337 + } 338 + 339 + /// A mention facet feature representing a reference to another user. 340 + /// 341 + /// When detecting mentions, `handle` is populated but `did` is nil. 342 + /// Before posting, you should resolve the handle to a DID. 343 + public struct RichTextMention: Codable, Sendable { 344 + /// The handle being mentioned (e.g., "alice.bsky.social"). 345 + /// Populated during detection, before DID resolution. 346 + public let handle: String? 347 + 348 + /// The DID of the mentioned user. 349 + /// Required for posting; resolve from handle if needed. 350 + public let did: String? 351 + 352 + /// Creates a mention feature. 353 + /// - Parameters: 354 + /// - handle: The user's handle (before resolution). 355 + /// - did: The user's DID (after resolution). 356 + public init(handle: String? = nil, did: String? = nil) { 357 + self.handle = handle 358 + self.did = did 359 + } 360 + } 361 + 362 + /// A hashtag facet feature for content discovery. 363 + /// 364 + /// Tags enable searching and browsing posts by topic. 365 + public struct RichTextTag: Codable, Sendable { 366 + /// The tag text without the leading '#'. 367 + public let tag: String 368 + 369 + /// Creates a tag feature with the given text. 370 + /// - Parameter tag: The tag text (without '#'). 371 + public init(tag: String) { 372 + self.tag = tag 373 + } 374 + } 375 + 376 + // MARK: - Extensions 377 + 378 + extension RichText { 379 + /// Returns facets in the format expected by the API 380 + public func toAPIFacets() -> [[String: Any]] { 381 + facets.map { facet in 382 + var dict: [String: Any] = [ 383 + "index": [ 384 + "byteStart": facet.index.byteStart, 385 + "byteEnd": facet.index.byteEnd 386 + ] 387 + ] 388 + 389 + let features: [[String: Any]] = facet.features.map { feature in 390 + switch feature { 391 + case .link(let link): 392 + return ["$type": "app.bsky.richtext.facet#link", "uri": link.uri] 393 + case .mention(let mention): 394 + if let did = mention.did { 395 + return ["$type": "app.bsky.richtext.facet#mention", "did": did] 396 + } 397 + return [:] 398 + case .tag(let tag): 399 + return ["$type": "app.bsky.richtext.facet#tag", "tag": tag.tag] 400 + } 401 + } 402 + 403 + dict["features"] = features 404 + return dict 405 + } 406 + } 407 + }
+2
Sources/bskyKit/bskyKit.swift
··· 1 + // The Swift Programming Language 2 + // https://docs.swift.org/swift-book
+332
Tests/bskyKitTests/ModelDecodingTests.swift
··· 1 + import Testing 2 + import Foundation 3 + @testable import bskyKit 4 + 5 + @Suite("Model Decoding Tests") 6 + struct ModelDecodingTests { 7 + 8 + // MARK: - Helper 9 + 10 + private func decode<T: Decodable>(_ type: T.Type, from json: String) throws -> T { 11 + let data = Data(json.utf8) 12 + let decoder = JSONDecoder.atDecoder 13 + return try decoder.decode(type, from: data) 14 + } 15 + 16 + // MARK: - Profile 17 + 18 + @Test("Decodes basic profile") 19 + func decodesBasicProfile() throws { 20 + let json = """ 21 + { 22 + "did": "did:plc:abc123", 23 + "handle": "alice.bsky.social", 24 + "displayName": "Alice", 25 + "description": "Hello world", 26 + "avatar": "https://example.com/avatar.jpg", 27 + "indexedAt": "2024-01-15T10:30:00.000Z" 28 + } 29 + """ 30 + 31 + let profile = try decode(Profile.self, from: json) 32 + #expect(profile.did == "did:plc:abc123") 33 + #expect(profile.handle == "alice.bsky.social") 34 + #expect(profile.displayName == "Alice") 35 + #expect(profile.description == "Hello world") 36 + #expect(profile.avatar == "https://example.com/avatar.jpg") 37 + #expect(profile.indexedAt != nil) 38 + #expect(profile.id == "did:plc:abc123") 39 + } 40 + 41 + @Test("Decodes profile with missing optional fields") 42 + func decodesProfileMissingOptionals() throws { 43 + let json = """ 44 + { 45 + "did": "did:plc:abc123", 46 + "handle": "alice.bsky.social" 47 + } 48 + """ 49 + 50 + let profile = try decode(Profile.self, from: json) 51 + #expect(profile.did == "did:plc:abc123") 52 + #expect(profile.displayName == nil) 53 + #expect(profile.avatar == nil) 54 + } 55 + 56 + // MARK: - Viewer 57 + 58 + @Test("Decodes viewer state") 59 + func decodesViewerState() throws { 60 + let json = """ 61 + { 62 + "muted": false, 63 + "blockedBy": false, 64 + "following": "at://did:plc:xyz/app.bsky.graph.follow/123" 65 + } 66 + """ 67 + 68 + let viewer = try decode(Viewer.self, from: json) 69 + #expect(viewer.muted == false) 70 + #expect(viewer.blockedBy == false) 71 + #expect(viewer.following == "at://did:plc:xyz/app.bsky.graph.follow/123") 72 + #expect(viewer.followedBy == nil) 73 + } 74 + 75 + // MARK: - Feed 76 + 77 + @Test("Decodes feed") 78 + func decodesFeed() throws { 79 + let json = """ 80 + { 81 + "uri": "at://did:plc:abc/app.bsky.feed.generator/following", 82 + "cid": "bafyreiabc123", 83 + "did": "did:plc:abc", 84 + "creator": { 85 + "did": "did:plc:creator", 86 + "handle": "creator.bsky.social" 87 + }, 88 + "displayName": "Following", 89 + "likeCount": 100, 90 + "indexedAt": "2024-01-01T00:00:00.000Z" 91 + } 92 + """ 93 + 94 + let feed = try decode(Feed.self, from: json) 95 + #expect(feed.uri == "at://did:plc:abc/app.bsky.feed.generator/following") 96 + #expect(feed.displayName == "Following") 97 + #expect(feed.likeCount == 100) 98 + #expect(feed.id == "at://did:plc:abc/app.bsky.feed.generator/following") 99 + } 100 + 101 + // MARK: - Follows 102 + 103 + @Test("Decodes follows response") 104 + func decodesFollowsResponse() throws { 105 + let json = """ 106 + { 107 + "subject": { 108 + "did": "did:plc:subject", 109 + "handle": "subject.bsky.social" 110 + }, 111 + "follows": [ 112 + { 113 + "did": "did:plc:follow1", 114 + "handle": "follow1.bsky.social", 115 + "displayName": "Follow 1" 116 + }, 117 + { 118 + "did": "did:plc:follow2", 119 + "handle": "follow2.bsky.social" 120 + } 121 + ], 122 + "cursor": "cursor123" 123 + } 124 + """ 125 + 126 + let follows = try decode(Follows.self, from: json) 127 + #expect(follows.subject.did == "did:plc:subject") 128 + #expect(follows.follows.count == 2) 129 + #expect(follows.follows[0].displayName == "Follow 1") 130 + #expect(follows.follows[1].displayName == nil) 131 + #expect(follows.cursor == "cursor123") 132 + } 133 + 134 + // MARK: - Notifications 135 + 136 + @Test("Decodes notification") 137 + func decodesNotification() throws { 138 + let json = """ 139 + { 140 + "uri": "at://did:plc:abc/app.bsky.feed.like/123", 141 + "cid": "bafyreiabc", 142 + "author": { 143 + "did": "did:plc:author", 144 + "handle": "author.bsky.social" 145 + }, 146 + "reason": "like", 147 + "isRead": false, 148 + "indexedAt": "2024-01-15T10:30:00.000Z" 149 + } 150 + """ 151 + 152 + let notification = try decode(Notification.self, from: json) 153 + #expect(notification.uri == "at://did:plc:abc/app.bsky.feed.like/123") 154 + #expect(notification.author.handle == "author.bsky.social") 155 + #expect(notification.reason == .like) 156 + #expect(notification.isRead == false) 157 + #expect(notification.id == "at://did:plc:abc/app.bsky.feed.like/123") 158 + } 159 + 160 + @Test("Decodes all notification reasons") 161 + func decodesNotificationReasons() throws { 162 + let reasons = ["like", "repost", "follow", "mention", "reply", "quote", "starterpack-joined"] 163 + 164 + for reason in reasons { 165 + let json = """ 166 + { 167 + "uri": "at://did:plc:abc/collection/123", 168 + "cid": "bafyreiabc", 169 + "author": { 170 + "did": "did:plc:author", 171 + "handle": "author.bsky.social" 172 + }, 173 + "reason": "\(reason)", 174 + "isRead": true, 175 + "indexedAt": "2024-01-15T10:30:00.000Z" 176 + } 177 + """ 178 + 179 + let notification = try decode(Notification.self, from: json) 180 + #expect(notification.reason != nil) 181 + } 182 + } 183 + 184 + // MARK: - Unread Count 185 + 186 + @Test("Decodes unread count") 187 + func decodesUnreadCount() throws { 188 + let json = """ 189 + { 190 + "count": 42 191 + } 192 + """ 193 + 194 + let unreadCount = try decode(UnreadCount.self, from: json) 195 + #expect(unreadCount.count == 42) 196 + } 197 + 198 + // MARK: - Search Actors 199 + 200 + @Test("Decodes search actors result") 201 + func decodesSearchActorsResult() throws { 202 + let json = """ 203 + { 204 + "actors": [ 205 + { 206 + "did": "did:plc:actor1", 207 + "handle": "actor1.bsky.social", 208 + "displayName": "Actor One" 209 + } 210 + ], 211 + "cursor": "next" 212 + } 213 + """ 214 + 215 + let result = try decode(SearchActorsResult.self, from: json) 216 + #expect(result.actors.count == 1) 217 + #expect(result.actors[0].handle == "actor1.bsky.social") 218 + #expect(result.cursor == "next") 219 + } 220 + 221 + // MARK: - Likes 222 + 223 + @Test("Decodes likes response") 224 + func decodesLikesResponse() throws { 225 + let json = """ 226 + { 227 + "uri": "at://did:plc:post/app.bsky.feed.post/123", 228 + "cid": "bafyreiabc", 229 + "likes": [ 230 + { 231 + "actor": { 232 + "did": "did:plc:liker", 233 + "handle": "liker.bsky.social" 234 + }, 235 + "createdAt": "2024-01-15T10:30:00.000Z", 236 + "indexedAt": "2024-01-15T10:30:00.000Z" 237 + } 238 + ], 239 + "cursor": "next" 240 + } 241 + """ 242 + 243 + let likes = try decode(Likes.self, from: json) 244 + #expect(likes.uri == "at://did:plc:post/app.bsky.feed.post/123") 245 + #expect(likes.likes.count == 1) 246 + #expect(likes.likes[0].actor.handle == "liker.bsky.social") 247 + } 248 + 249 + // MARK: - Blocks 250 + 251 + @Test("Decodes blocks response") 252 + func decodesBlocksResponse() throws { 253 + let json = """ 254 + { 255 + "blocks": [ 256 + { 257 + "did": "did:plc:blocked", 258 + "handle": "blocked.bsky.social" 259 + } 260 + ] 261 + } 262 + """ 263 + 264 + let blocks = try decode(Blocks.self, from: json) 265 + #expect(blocks.blocks.count == 1) 266 + #expect(blocks.cursor == nil) 267 + } 268 + 269 + // MARK: - Date Formats 270 + 271 + @Test("Decodes various date formats") 272 + func decodesDateFormats() throws { 273 + let dateFormats = [ 274 + "2024-01-15T10:30:00.000Z", 275 + "2024-01-15T10:30:00Z", 276 + "2024-01-15T10:30:00.123456Z", 277 + "2024-01-15T10:30:00+00:00" 278 + ] 279 + 280 + for dateString in dateFormats { 281 + let json = """ 282 + { 283 + "did": "did:plc:abc", 284 + "handle": "test.bsky.social", 285 + "indexedAt": "\(dateString)" 286 + } 287 + """ 288 + 289 + let profile = try decode(Profile.self, from: json) 290 + #expect(profile.indexedAt != nil, "Failed to decode date: \(dateString)") 291 + } 292 + } 293 + } 294 + 295 + // MARK: - JSONDecoder Extension 296 + 297 + extension JSONDecoder { 298 + static var atDecoder: JSONDecoder { 299 + let decoder = JSONDecoder() 300 + decoder.dateDecodingStrategy = .custom { decoder in 301 + let container = try decoder.singleValueContainer() 302 + let dateString = try container.decode(String.self) 303 + 304 + // Try various ISO 8601 formats 305 + let formatters = [ 306 + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", 307 + "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", 308 + "yyyy-MM-dd'T'HH:mm:ssXXXXX", 309 + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", 310 + "yyyy-MM-dd'T'HH:mm:ss'Z'" 311 + ] 312 + 313 + for format in formatters { 314 + let formatter = DateFormatter() 315 + formatter.dateFormat = format 316 + formatter.locale = Locale(identifier: "en_US_POSIX") 317 + formatter.timeZone = TimeZone(secondsFromGMT: 0) 318 + if let date = formatter.date(from: dateString) { 319 + return date 320 + } 321 + } 322 + 323 + throw DecodingError.dataCorrupted( 324 + DecodingError.Context( 325 + codingPath: decoder.codingPath, 326 + debugDescription: "Cannot decode date: \(dateString)" 327 + ) 328 + ) 329 + } 330 + return decoder 331 + } 332 + }
+179
Tests/bskyKitTests/RichTextTests.swift
··· 1 + import Testing 2 + @testable import bskyKit 3 + 4 + @Suite("RichText Tests") 5 + struct RichTextTests { 6 + 7 + // MARK: - Basic Text 8 + 9 + @Test("Plain text has no facets") 10 + func plainTextNoFacets() { 11 + let richText = RichText.detect(in: "Hello, world!") 12 + #expect(richText.text == "Hello, world!") 13 + #expect(richText.facets.isEmpty) 14 + } 15 + 16 + // MARK: - Link Detection 17 + 18 + @Test("Detects simple URL") 19 + func detectsSimpleURL() { 20 + let richText = RichText.detect(in: "Check out https://example.com today") 21 + #expect(richText.facets.count == 1) 22 + 23 + let facet = richText.facets[0] 24 + #expect(facet.index.byteStart == 10) 25 + #expect(facet.index.byteEnd == 29) 26 + 27 + if case .link(let link) = facet.features[0] { 28 + #expect(link.uri == "https://example.com") 29 + } else { 30 + Issue.record("Expected link facet") 31 + } 32 + } 33 + 34 + @Test("Detects multiple URLs") 35 + func detectsMultipleURLs() { 36 + let richText = RichText.detect(in: "Visit https://a.com and https://b.com") 37 + #expect(richText.facets.count == 2) 38 + } 39 + 40 + // MARK: - Mention Detection 41 + 42 + @Test("Detects simple mention") 43 + func detectsSimpleMention() { 44 + let richText = RichText.detect(in: "Hello @alice.bsky.social!") 45 + #expect(richText.facets.count == 1) 46 + 47 + let facet = richText.facets[0] 48 + if case .mention(let mention) = facet.features[0] { 49 + #expect(mention.handle == "alice.bsky.social") 50 + } else { 51 + Issue.record("Expected mention facet") 52 + } 53 + } 54 + 55 + @Test("Detects mention at start") 56 + func detectsMentionAtStart() { 57 + let richText = RichText.detect(in: "@alice hello") 58 + #expect(richText.facets.count == 1) 59 + #expect(richText.facets[0].index.byteStart == 0) 60 + } 61 + 62 + @Test("Detects multiple mentions") 63 + func detectsMultipleMentions() { 64 + let richText = RichText.detect(in: "Hey @alice and @bob") 65 + #expect(richText.facets.count == 2) 66 + } 67 + 68 + // MARK: - Hashtag Detection 69 + 70 + @Test("Detects simple hashtag") 71 + func detectsSimpleHashtag() { 72 + let richText = RichText.detect(in: "Love this #atproto") 73 + #expect(richText.facets.count == 1) 74 + 75 + let facet = richText.facets[0] 76 + if case .tag(let tag) = facet.features[0] { 77 + #expect(tag.tag == "atproto") 78 + } else { 79 + Issue.record("Expected tag facet") 80 + } 81 + } 82 + 83 + @Test("Detects hashtag with underscores") 84 + func detectsHashtagWithUnderscores() { 85 + let richText = RichText.detect(in: "Check #my_cool_tag") 86 + #expect(richText.facets.count == 1) 87 + 88 + if case .tag(let tag) = richText.facets[0].features[0] { 89 + #expect(tag.tag == "my_cool_tag") 90 + } else { 91 + Issue.record("Expected tag facet") 92 + } 93 + } 94 + 95 + @Test("Does not detect hashtag starting with number") 96 + func noHashtagStartingWithNumber() { 97 + let richText = RichText.detect(in: "Not a tag #123abc") 98 + // Should not detect #123abc as a valid hashtag 99 + let tagFacets = richText.facets.filter { 100 + if case .tag = $0.features[0] { return true } 101 + return false 102 + } 103 + #expect(tagFacets.isEmpty) 104 + } 105 + 106 + // MARK: - Mixed Content 107 + 108 + @Test("Detects mixed content") 109 + func detectsMixedContent() { 110 + let richText = RichText.detect(in: "Hey @alice check https://example.com #cool") 111 + #expect(richText.facets.count == 3) 112 + 113 + // Facets should be sorted by byte position 114 + var hasMention = false 115 + var hasLink = false 116 + var hasTag = false 117 + 118 + for facet in richText.facets { 119 + switch facet.features[0] { 120 + case .mention: hasMention = true 121 + case .link: hasLink = true 122 + case .tag: hasTag = true 123 + } 124 + } 125 + 126 + #expect(hasMention) 127 + #expect(hasLink) 128 + #expect(hasTag) 129 + } 130 + 131 + // MARK: - Byte Index Conversion 132 + 133 + @Test("Byte index for ASCII") 134 + func byteIndexASCII() { 135 + let richText = RichText(text: "Hello") 136 + let index = richText.text.index(richText.text.startIndex, offsetBy: 3) 137 + #expect(richText.byteIndex(from: index) == 3) 138 + } 139 + 140 + @Test("Byte index for emoji") 141 + func byteIndexEmoji() { 142 + // Emoji takes 4 bytes in UTF-8 143 + let richText = RichText(text: "Hi 👋 there") 144 + let index = richText.text.index(richText.text.startIndex, offsetBy: 5) // After emoji 145 + // "Hi " = 3 bytes, "👋" = 4 bytes, " " = 1 byte... index 5 is 't' 146 + // Actually "Hi " is 3 chars/bytes, emoji is 1 char but 4 bytes 147 + // Index 5 (char index) = "t" which comes after "Hi 👋 " = 3 + 4 + 1 = 8 bytes 148 + #expect(richText.byteIndex(from: index) == 8) 149 + } 150 + 151 + @Test("Character index from byte index") 152 + func characterIndexFromByte() { 153 + let richText = RichText(text: "Hello") 154 + let charIndex = richText.characterIndex(from: 3) 155 + #expect(charIndex != nil) 156 + #expect(richText.text[charIndex!] == "l") 157 + } 158 + 159 + // MARK: - Edge Cases 160 + 161 + @Test("Empty text") 162 + func emptyText() { 163 + let richText = RichText.detect(in: "") 164 + #expect(richText.text.isEmpty) 165 + #expect(richText.facets.isEmpty) 166 + } 167 + 168 + @Test("URL at end without space") 169 + func urlAtEndNoSpace() { 170 + let richText = RichText.detect(in: "Visit https://example.com") 171 + #expect(richText.facets.count == 1) 172 + } 173 + 174 + @Test("Multiple spaces between mentions") 175 + func multipleSpacesBetweenMentions() { 176 + let richText = RichText.detect(in: "@alice @bob") 177 + #expect(richText.facets.count == 2) 178 + } 179 + }
+6
Tests/bskyKitTests/bskyKitTests.swift
··· 1 + import Testing 2 + @testable import bskyKit 3 + 4 + @Test func example() async throws { 5 + // Write your test here and use APIs like `#expect(...)` to check expected conditions. 6 + }