this repo has no description
Swift 97.8%
Other 2.2%
1 1 0

Clone this repository

https://tangled.org/sparrowtek.com/bskyKit
git@tangled.org:sparrowtek.com/bskyKit

For self-hosted knots, clone URLs may differ based on your setup.

README.md

bskyKit#

A Swift SDK for building Bluesky 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, and blocks
  • Rich Text - Automatic detection and creation of mentions, links, and hashtags with proper byte indexing
  • Type Safety - Fully typed models for all API responses with Codable and Sendable conformance
  • SwiftUI Ready - Models conform to Identifiable for seamless use in SwiftUI lists

Built on 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:

dependencies: [
    .package(url: "https://tangled.org/@sparrowtek.com/bskyKit", branch: "main"),
]

Then add it to your target:

.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:

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:

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:

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
getPreferences() Get authenticated user's preferences
searchActors(query:limit:) Search users by name/handle/bio
searchActorsTypeahead(query:limit:) Fast search for autocomplete

Feed Operations#

Method Description
getTimeline(limit:cursor:) Get home timeline
getAuthorFeed(for:limit:cursor:) Get a user's posts
getPostThread(uri:depth:) Get post with replies
getPosts(uris:) Fetch multiple posts by URI
getFeedGenerators(for:) Get custom feed info
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

Notification Operations#

Method Description
listNotifications(limit:cursor:) Get notifications
getUnreadCount() Get unread notification count
updateSeen(at:) Mark notifications as read

RepoService (Write Operations)#

High-Level Methods#

// 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#

createRecord(repo:collection:record:rkey:) -> CreateRecordResponse
deleteRecord(repo:collection:rkey:)
getRecord(repo:collection:rkey:) -> GetRecordResponse
listRecords(repo:collection:limit:cursor:) -> ListRecordsResponse

Rich Text#

bskyKit handles the complexity of AT Protocol rich text facets automatically.

Automatic Detection#

The easiest approach - facets are detected automatically:

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:

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)")
    }
}

Byte Index Handling#

AT Protocol uses UTF-8 byte indices, not character indices. bskyKit handles this automatically, but if you need manual conversion:

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:

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#

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#

let post = PostRecord(
    text: "Check out this post!",
    embed: .record(RecordEmbed(uri: quotedPostURI, cid: quotedPostCID))
)
try await repo.createPost(post, repo: myDID)
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#

public enum NotificationReason: String {
    case like
    case repost
    case follow
    case mention
    case reply
    case quote
    case starterpackJoined
}

Thread Safety#

All services use @APActor for thread-safe access:

@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:

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:

swift test

The test suite includes:

  • Rich text detection (links, mentions, hashtags)
  • Byte index conversion
  • Model decoding

License#

This project is licensed under an MIT 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.