# bskyKit 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. ## Overview bskyKit implements the `app.bsky.*` lexicons for the AT Protocol, giving you everything needed to build a full-featured Bluesky client: - **Read Operations** - Fetch timelines, profiles, posts, threads, notifications, and social graphs - **Write Operations** - Create posts, likes, reposts, follows, blocks, and low-level repo writes - **Rich Text** - Automatic detection and creation of mentions, links, and hashtags with proper byte indexing - **Type Safety** - Typed models for stable endpoints plus `JSONValue` for rapidly evolving/unspecced payloads - **SwiftUI Ready** - Models conform to `Identifiable` for seamless use in SwiftUI lists `BskyService` now covers all current `app.bsky` query/procedure lexicons, including actor/feed/graph/notification plus age-assurance, unspecced, bookmark, and video endpoints. Built on [CoreATProtocol](https://tangled.org/@sparrowtek.com/CoreATProtocol) for networking, authentication, and token management. ## Requirements - Swift 6.2+ - iOS 26.0+ / macOS 26.0+ / watchOS 26.0+ / tvOS 26.0+ / Mac Catalyst 26.0+ ## Installation ### Swift Package Manager Add bskyKit to your `Package.swift` dependencies: ```swift dependencies: [ .package(url: "https://tangled.org/@sparrowtek.com/bskyKit", branch: "main"), ] ``` Then add it to your target: ```swift .target( name: "YourApp", dependencies: ["bskyKit"] ), ``` Or in Xcode: **File > Add Package Dependencies** and enter: ``` https://tangled.org/@sparrowtek.com/bskyKit ``` ## Quick Start ### Setup Configure the environment before making any API calls: ```swift import bskyKit import CoreATProtocol // Configure with your PDS host and authentication tokens await setup( hostURL: "https://bsky.social", accessJWT: "your-access-token", refreshJWT: "your-refresh-token" ) ``` ### Reading Data Use `BskyService` for all read operations: ```swift let service = await BskyService() // Fetch a profile let profile = try await service.getProfile(for: "alice.bsky.social") print("\(profile.displayName ?? profile.handle) has \(profile.followersCount ?? 0) followers") // Get your timeline let timeline = try await service.getTimeline(limit: 50) for item in timeline.feed { print("\(item.post.author.handle): \(item.post.record.text ?? "")") } // Search for users let results = try await service.searchActors(query: "swift developer", limit: 10) ``` ### Creating Content Use `RepoService` for write operations: ```swift let repo = await RepoService() let myDID = "did:plc:your-did-here" // Create a simple post let post = PostRecord.create(text: "Hello from bskyKit!") let result = try await repo.createPost(post, repo: myDID) // Create a post with rich text (auto-detected) let richPost = PostRecord.create( text: "Hey @alice.bsky.social check out https://example.com #swift" ) // Mentions, links, and hashtags are automatically detected! try await repo.createPost(richPost, repo: myDID) // Like a post try await repo.like(uri: postURI, cid: postCID, repo: myDID) // Follow someone try await repo.follow(did: "did:plc:someone", repo: myDID) ``` ## API Reference ### BskyService (Read Operations) #### Actor Operations | Method | Description | |--------|-------------| | `getProfile(for:)` | Fetch a user profile by handle or DID | | `getProfiles(for:)` | Fetch multiple profiles in one request | | `getSuggestions(limit:cursor:)` | Get actor suggestions | | `getPreferences()` | Get authenticated user's preferences | | `searchActors(query:limit:)` | Search users by name/handle/bio | | `searchActorsTypeahead(query:limit:)` | Fast search for autocomplete | | `putPreferences(_:)` | Update actor preferences | #### Feed Operations | Method | Description | |--------|-------------| | `getTimeline(limit:cursor:)` | Get home timeline | | `getFeed(feed:limit:cursor:)` | Get posts from a feed generator | | `getAuthorFeed(for:limit:cursor:)` | Get a user's posts | | `getPostThread(uri:depth:)` | Get post with replies | | `getPosts(uris:)` | Fetch multiple posts by URI | | `searchPosts(...)` | Search posts | | `getQuotes(uri:cid:limit:cursor:)` | Get quotes of a post | | `getListFeed(list:limit:cursor:)` | Get posts from a list feed | | `getActorFeeds(for:limit:cursor:)` | Get feed generators by actor | | `getFeedGenerator(feed:)` | Get one feed generator | | `getFeedGenerators(for:)` | Get custom feed info | | `getSuggestedFeeds(limit:cursor:)` | Get suggested feed generators | | `getLikes(uri:limit:cursor:)` | Get users who liked a post | | `getRepostedBy(uri:limit:cursor:)` | Get users who reposted | #### Graph Operations | Method | Description | |--------|-------------| | `getFollows(for:limit:cursor:)` | Get who a user follows | | `getFollowers(for:limit:cursor:)` | Get a user's followers | | `getBlocks(limit:cursor:)` | Get your blocked accounts | | `getMutes(limit:cursor:)` | Get your muted accounts | | `getRelationships(for:others:)` | Get relationship state for actors | | `muteActor(_:)` / `unmuteActor(_:)` | Mute or unmute actor | | `muteThread(root:)` / `unmuteThread(root:)` | Mute or unmute thread | | `muteActorList(_:)` / `unmuteActorList(_:)` | Mute or unmute actor list | #### Notification Operations | Method | Description | |--------|-------------| | `listNotifications(limit:cursor:)` | Get notifications | | `getUnreadCount()` | Get unread notification count | | `updateSeen(at:)` | Mark notifications as read | | `getNotificationPreferences()` | Read notification preferences | | `putNotificationPreferences(priority:)` | Update legacy notification preferences | | `putNotificationPreferencesV2(_:)` | Update v2 notification preferences | ### RepoService (Write Operations) #### High-Level Methods ```swift // Posts createPost(_ post: PostRecord, repo: String) -> CreateRecordResponse // Interactions like(uri:cid:repo:) -> CreateRecordResponse unlike(uri:repo:) repost(uri:cid:repo:) -> CreateRecordResponse unrepost(uri:repo:) // Social Graph follow(did:repo:) -> CreateRecordResponse unfollow(uri:repo:) block(did:repo:) -> CreateRecordResponse unblock(uri:repo:) ``` #### Low-Level Record Operations ```swift createRecord(repo:collection:record:rkey:) -> CreateRecordResponse putRecord(repo:collection:rkey:record:validate:swapRecord:swapCommit:) -> PutRecordResponse applyWrites(repo:writes:validate:swapCommit:) -> ApplyWritesResponse deleteRecord(repo:collection:rkey:) describeRepo(repo:) -> DescribeRepoResponse getRecord(repo:collection:rkey:) -> GetRecordResponse listRecords(repo:collection:limit:cursor:) -> ListRecordsResponse listMissingBlobs(limit:cursor:) -> ListMissingBlobsResponse importRepo(car:) uploadBlob(data:mimeType:) -> BlobResponse ``` ## Rich Text bskyKit handles the complexity of AT Protocol rich text facets automatically. ### Automatic Detection The easiest approach - facets are detected automatically: ```swift let post = PostRecord.create( text: "Hey @alice.bsky.social! Check https://swift.org #SwiftLang" ) // post.facets contains 3 facets: mention, link, and hashtag ``` ### Manual Rich Text Processing For more control, use the `RichText` type directly: ```swift let text = "Hello @bob.bsky.social!" let richText = RichText.detect(in: text) for facet in richText.facets { switch facet.features[0] { case .mention(let mention): print("Mentioned: \(mention.handle ?? "unknown")") // Resolve handle to DID before posting case .link(let link): print("Link to: \(link.uri)") case .tag(let tag): print("Hashtag: #\(tag.tag)") case .unknown: break } } ``` ### Byte Index Handling AT Protocol uses UTF-8 byte indices, not character indices. bskyKit handles this automatically, but if you need manual conversion: ```swift let text = "Hi 👋 there" // Emoji = 4 bytes let richText = RichText(text: text) // Convert character index to byte index let charIndex = text.index(text.startIndex, offsetBy: 4) let byteIndex = richText.byteIndex(from: charIndex) // Returns 7 // Convert byte index to character index let charIdx = richText.characterIndex(from: 7) ``` ## Pagination All list endpoints support cursor-based pagination: ```swift let service = await BskyService() // First page var timeline = try await service.getTimeline(limit: 50) displayPosts(timeline.feed) // Load more while let cursor = timeline.cursor { timeline = try await service.getTimeline(limit: 50, cursor: cursor) displayPosts(timeline.feed) } ``` ## Creating Posts with Embeds ### Reply to a Post ```swift let replyRef = ReplyRef( root: PostRef(uri: rootPostURI, cid: rootPostCID), parent: PostRef(uri: parentPostURI, cid: parentPostCID) ) let post = PostRecord.create( text: "This is my reply!", reply: replyRef ) try await repo.createPost(post, repo: myDID) ``` ### Quote Post ```swift let post = PostRecord( text: "Check out this post!", embed: .record(RecordEmbed(uri: quotedPostURI, cid: quotedPostCID)) ) try await repo.createPost(post, repo: myDID) ``` ### External Link Card ```swift let post = PostRecord( text: "Great article", embed: .external(ExternalEmbed( uri: "https://example.com/article", title: "Article Title", description: "A brief description of the article" )) ) try await repo.createPost(post, repo: myDID) ``` ## Models All models are `Codable`, `Sendable`, and most are `Identifiable` for SwiftUI compatibility. ### Key Types | Type | Description | |------|-------------| | `Profile` | User profile with stats and viewer state | | `Timeline` | Home timeline with cursor | | `TimelineItem` | A post in the timeline (may include reply context) | | `Post` | Full post with author, record, embed, and stats | | `PostThread` | Thread view with replies | | `Notification` | Notification with reason and content | | `Feed` | Custom feed generator info | | `Follows` / `Followers` | Social graph lists | ### Notification Reasons ```swift public enum NotificationReason: Codable, Sendable, Equatable, Hashable { case like case repost case follow case mention case reply case quote case starterpackJoined case unknown(String) } ``` ## Thread Safety All services use `@APActor` for thread-safe access: ```swift @APActor func loadTimeline() async throws { let service = BskyService() // Safe to create on APActor let timeline = try await service.getTimeline() // Process timeline... } ``` ## Error Handling Errors are typed via `AtError` from CoreATProtocol: ```swift do { let profile = try await service.getProfile(for: "nonexistent.handle") } catch let error as AtError { switch error { case .message(let msg): // API error (e.g., "ProfileNotFound") print("Error: \(msg.error) - \(msg.message ?? "")") case .network(let networkError): // Network/HTTP error print("Network error: \(networkError)") } } ``` ## Testing bskyKit uses Swift Testing. Run tests with: ```bash swift test ``` The test suite includes: - Rich text detection (links, mentions, hashtags) - Byte index conversion - Model decoding ## Related Packages - **[CoreATProtocol](https://tangled.org/@sparrowtek.com/CoreATProtocol)** - Core networking layer (dependency) ## License This project is licensed under an [MIT license](https://tangled.org/sparrowtek.com/bskyKit/blob/main/LICENSE). ## Contributing 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. 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).