···11+22+# Contributor Covenant Code of Conduct
33+44+## Our Pledge
55+66+We as members, contributors, and leaders pledge to make participation in our
77+community a harassment-free experience for everyone, regardless of age, body
88+size, visible or invisible disability, ethnicity, sex characteristics, gender
99+identity and expression, level of experience, education, socio-economic status,
1010+nationality, personal appearance, race, caste, color, religion, or sexual
1111+identity and orientation.
1212+1313+We pledge to act and interact in ways that contribute to an open, welcoming,
1414+diverse, inclusive, and healthy community.
1515+1616+## Our Standards
1717+1818+Examples of behavior that contributes to a positive environment for our
1919+community include:
2020+2121+* Demonstrating empathy and kindness toward other people
2222+* Being respectful of differing opinions, viewpoints, and experiences
2323+* Giving and gracefully accepting constructive feedback
2424+* Accepting responsibility and apologizing to those affected by our mistakes,
2525+ and learning from the experience
2626+* Focusing on what is best not just for us as individuals, but for the overall
2727+ community
2828+2929+Examples of unacceptable behavior include:
3030+3131+* The use of sexualized language or imagery, and sexual attention or advances of
3232+ any kind
3333+* Trolling, insulting or derogatory comments, and personal or political attacks
3434+* Public or private harassment
3535+* Publishing others' private information, such as a physical or email address,
3636+ without their explicit permission
3737+* Other conduct which could reasonably be considered inappropriate in a
3838+ professional setting
3939+4040+## Enforcement Responsibilities
4141+4242+Community leaders are responsible for clarifying and enforcing our standards of
4343+acceptable behavior and will take appropriate and fair corrective action in
4444+response to any behavior that they deem inappropriate, threatening, offensive,
4545+or harmful.
4646+4747+Community leaders have the right and responsibility to remove, edit, or reject
4848+comments, commits, code, wiki edits, issues, and other contributions that are
4949+not aligned to this Code of Conduct, and will communicate reasons for moderation
5050+decisions when appropriate.
5151+5252+## Scope
5353+5454+This Code of Conduct applies within all community spaces, and also applies when
5555+an individual is officially representing the community in public spaces.
5656+Examples of representing our community include using an official e-mail address,
5757+posting via an official social media account, or acting as an appointed
5858+representative at an online or offline event.
5959+6060+## Enforcement
6161+6262+Instances of abusive, harassing, or otherwise unacceptable behavior may be
6363+reported to the community leaders responsible for enforcement at
6464+contact@sparrowtek.com.
6565+All complaints will be reviewed and investigated promptly and fairly.
6666+6767+All community leaders are obligated to respect the privacy and security of the
6868+reporter of any incident.
6969+7070+## Enforcement Guidelines
7171+7272+Community leaders will follow these Community Impact Guidelines in determining
7373+the consequences for any action they deem in violation of this Code of Conduct:
7474+7575+### 1. Correction
7676+7777+**Community Impact**: Use of inappropriate language or other behavior deemed
7878+unprofessional or unwelcome in the community.
7979+8080+**Consequence**: A private, written warning from community leaders, providing
8181+clarity around the nature of the violation and an explanation of why the
8282+behavior was inappropriate. A public apology may be requested.
8383+8484+### 2. Warning
8585+8686+**Community Impact**: A violation through a single incident or series of
8787+actions.
8888+8989+**Consequence**: A warning with consequences for continued behavior. No
9090+interaction with the people involved, including unsolicited interaction with
9191+those enforcing the Code of Conduct, for a specified period of time. This
9292+includes avoiding interactions in community spaces as well as external channels
9393+like social media. Violating these terms may lead to a temporary or permanent
9494+ban.
9595+9696+### 3. Temporary Ban
9797+9898+**Community Impact**: A serious violation of community standards, including
9999+sustained inappropriate behavior.
100100+101101+**Consequence**: A temporary ban from any sort of interaction or public
102102+communication with the community for a specified period of time. No public or
103103+private interaction with the people involved, including unsolicited interaction
104104+with those enforcing the Code of Conduct, is allowed during this period.
105105+Violating these terms may lead to a permanent ban.
106106+107107+### 4. Permanent Ban
108108+109109+**Community Impact**: Demonstrating a pattern of violation of community
110110+standards, including sustained inappropriate behavior, harassment of an
111111+individual, or aggression toward or disparagement of classes of individuals.
112112+113113+**Consequence**: A permanent ban from any sort of public interaction within the
114114+community.
115115+116116+## Attribution
117117+118118+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
119119+version 2.1, available at
120120+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
121121+122122+Community Impact Guidelines were inspired by
123123+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
124124+125125+For answers to common questions about this code of conduct, see the FAQ at
126126+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
127127+[https://www.contributor-covenant.org/translations][translations].
128128+129129+[homepage]: https://www.contributor-covenant.org
130130+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
131131+[Mozilla CoC]: https://github.com/mozilla/diversity
132132+[FAQ]: https://www.contributor-covenant.org/faq
133133+[translations]: https://www.contributor-covenant.org/translations
+21
LICENSE
···11+MIT License
22+33+Copyright (c) 2026 SparrowTek
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
···11+# bskyKit
22+33+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.
44+55+## Overview
66+77+bskyKit implements the `app.bsky.*` lexicons for the AT Protocol, giving you everything needed to build a full-featured Bluesky client:
88+99+- **Read Operations** - Fetch timelines, profiles, posts, threads, notifications, and social graphs
1010+- **Write Operations** - Create posts, likes, reposts, follows, and blocks
1111+- **Rich Text** - Automatic detection and creation of mentions, links, and hashtags with proper byte indexing
1212+- **Type Safety** - Fully typed models for all API responses with `Codable` and `Sendable` conformance
1313+- **SwiftUI Ready** - Models conform to `Identifiable` for seamless use in SwiftUI lists
1414+1515+Built on [CoreATProtocol](https://tangled.org/@sparrowtek.com/CoreATProtocol) for networking, authentication, and token management.
1616+1717+## Requirements
1818+1919+- Swift 6.2+
2020+- iOS 26.0+ / macOS 26.0+ / watchOS 26.0+ / tvOS 26.0+ / Mac Catalyst 26.0+
2121+2222+## Installation
2323+2424+### Swift Package Manager
2525+2626+Add bskyKit to your `Package.swift` dependencies:
2727+2828+```swift
2929+dependencies: [
3030+ .package(url: "https://tangled.org/@sparrowtek.com/bskyKit", branch: "main"),
3131+]
3232+```
3333+3434+Then add it to your target:
3535+3636+```swift
3737+.target(
3838+ name: "YourApp",
3939+ dependencies: ["bskyKit"]
4040+),
4141+```
4242+4343+Or in Xcode: **File > Add Package Dependencies** and enter:
4444+```
4545+https://tangled.org/@sparrowtek.com/bskyKit
4646+```
4747+4848+## Quick Start
4949+5050+### Setup
5151+5252+Configure the environment before making any API calls:
5353+5454+```swift
5555+import bskyKit
5656+import CoreATProtocol
5757+5858+// Configure with your PDS host and authentication tokens
5959+await setup(
6060+ hostURL: "https://bsky.social",
6161+ accessJWT: "your-access-token",
6262+ refreshJWT: "your-refresh-token"
6363+)
6464+```
6565+6666+### Reading Data
6767+6868+Use `BskyService` for all read operations:
6969+7070+```swift
7171+let service = await BskyService()
7272+7373+// Fetch a profile
7474+let profile = try await service.getProfile(for: "alice.bsky.social")
7575+print("\(profile.displayName ?? profile.handle) has \(profile.followersCount ?? 0) followers")
7676+7777+// Get your timeline
7878+let timeline = try await service.getTimeline(limit: 50)
7979+for item in timeline.feed {
8080+ print("\(item.post.author.handle): \(item.post.record.text)")
8181+}
8282+8383+// Search for users
8484+let results = try await service.searchActors(query: "swift developer", limit: 10)
8585+```
8686+8787+### Creating Content
8888+8989+Use `RepoService` for write operations:
9090+9191+```swift
9292+let repo = await RepoService()
9393+let myDID = "did:plc:your-did-here"
9494+9595+// Create a simple post
9696+let post = PostRecord.create(text: "Hello from bskyKit!")
9797+let result = try await repo.createPost(post, repo: myDID)
9898+9999+// Create a post with rich text (auto-detected)
100100+let richPost = PostRecord.create(
101101+ text: "Hey @alice.bsky.social check out https://example.com #swift"
102102+)
103103+// Mentions, links, and hashtags are automatically detected!
104104+try await repo.createPost(richPost, repo: myDID)
105105+106106+// Like a post
107107+try await repo.like(uri: postURI, cid: postCID, repo: myDID)
108108+109109+// Follow someone
110110+try await repo.follow(did: "did:plc:someone", repo: myDID)
111111+```
112112+113113+## API Reference
114114+115115+### BskyService (Read Operations)
116116+117117+#### Actor Operations
118118+119119+| Method | Description |
120120+|--------|-------------|
121121+| `getProfile(for:)` | Fetch a user profile by handle or DID |
122122+| `getProfiles(for:)` | Fetch multiple profiles in one request |
123123+| `getPreferences()` | Get authenticated user's preferences |
124124+| `searchActors(query:limit:)` | Search users by name/handle/bio |
125125+| `searchActorsTypeahead(query:limit:)` | Fast search for autocomplete |
126126+127127+#### Feed Operations
128128+129129+| Method | Description |
130130+|--------|-------------|
131131+| `getTimeline(limit:cursor:)` | Get home timeline |
132132+| `getAuthorFeed(for:limit:cursor:)` | Get a user's posts |
133133+| `getPostThread(uri:depth:)` | Get post with replies |
134134+| `getPosts(uris:)` | Fetch multiple posts by URI |
135135+| `getFeedGenerators(for:)` | Get custom feed info |
136136+| `getLikes(uri:limit:cursor:)` | Get users who liked a post |
137137+| `getRepostedBy(uri:limit:cursor:)` | Get users who reposted |
138138+139139+#### Graph Operations
140140+141141+| Method | Description |
142142+|--------|-------------|
143143+| `getFollows(for:limit:cursor:)` | Get who a user follows |
144144+| `getFollowers(for:limit:cursor:)` | Get a user's followers |
145145+| `getBlocks(limit:cursor:)` | Get your blocked accounts |
146146+| `getMutes(limit:cursor:)` | Get your muted accounts |
147147+148148+#### Notification Operations
149149+150150+| Method | Description |
151151+|--------|-------------|
152152+| `listNotifications(limit:cursor:)` | Get notifications |
153153+| `getUnreadCount()` | Get unread notification count |
154154+| `updateSeen(at:)` | Mark notifications as read |
155155+156156+### RepoService (Write Operations)
157157+158158+#### High-Level Methods
159159+160160+```swift
161161+// Posts
162162+createPost(_ post: PostRecord, repo: String) -> CreateRecordResponse
163163+164164+// Interactions
165165+like(uri:cid:repo:) -> CreateRecordResponse
166166+unlike(uri:repo:)
167167+repost(uri:cid:repo:) -> CreateRecordResponse
168168+unrepost(uri:repo:)
169169+170170+// Social Graph
171171+follow(did:repo:) -> CreateRecordResponse
172172+unfollow(uri:repo:)
173173+block(did:repo:) -> CreateRecordResponse
174174+unblock(uri:repo:)
175175+```
176176+177177+#### Low-Level Record Operations
178178+179179+```swift
180180+createRecord(repo:collection:record:rkey:) -> CreateRecordResponse
181181+deleteRecord(repo:collection:rkey:)
182182+getRecord(repo:collection:rkey:) -> GetRecordResponse
183183+listRecords(repo:collection:limit:cursor:) -> ListRecordsResponse
184184+```
185185+186186+## Rich Text
187187+188188+bskyKit handles the complexity of AT Protocol rich text facets automatically.
189189+190190+### Automatic Detection
191191+192192+The easiest approach - facets are detected automatically:
193193+194194+```swift
195195+let post = PostRecord.create(
196196+ text: "Hey @alice.bsky.social! Check https://swift.org #SwiftLang"
197197+)
198198+// post.facets contains 3 facets: mention, link, and hashtag
199199+```
200200+201201+### Manual Rich Text Processing
202202+203203+For more control, use the `RichText` type directly:
204204+205205+```swift
206206+let text = "Hello @bob.bsky.social!"
207207+let richText = RichText.detect(in: text)
208208+209209+for facet in richText.facets {
210210+ switch facet.features[0] {
211211+ case .mention(let mention):
212212+ print("Mentioned: \(mention.handle ?? "unknown")")
213213+ // Resolve handle to DID before posting
214214+ case .link(let link):
215215+ print("Link to: \(link.uri)")
216216+ case .tag(let tag):
217217+ print("Hashtag: #\(tag.tag)")
218218+ }
219219+}
220220+```
221221+222222+### Byte Index Handling
223223+224224+AT Protocol uses UTF-8 byte indices, not character indices. bskyKit handles this automatically, but if you need manual conversion:
225225+226226+```swift
227227+let text = "Hi 👋 there" // Emoji = 4 bytes
228228+let richText = RichText(text: text)
229229+230230+// Convert character index to byte index
231231+let charIndex = text.index(text.startIndex, offsetBy: 4)
232232+let byteIndex = richText.byteIndex(from: charIndex) // Returns 7
233233+234234+// Convert byte index to character index
235235+let charIdx = richText.characterIndex(from: 7)
236236+```
237237+238238+## Pagination
239239+240240+All list endpoints support cursor-based pagination:
241241+242242+```swift
243243+let service = await BskyService()
244244+245245+// First page
246246+var timeline = try await service.getTimeline(limit: 50)
247247+displayPosts(timeline.feed)
248248+249249+// Load more
250250+while let cursor = timeline.cursor {
251251+ timeline = try await service.getTimeline(limit: 50, cursor: cursor)
252252+ displayPosts(timeline.feed)
253253+}
254254+```
255255+256256+## Creating Posts with Embeds
257257+258258+### Reply to a Post
259259+260260+```swift
261261+let replyRef = ReplyRef(
262262+ root: PostRef(uri: rootPostURI, cid: rootPostCID),
263263+ parent: PostRef(uri: parentPostURI, cid: parentPostCID)
264264+)
265265+266266+let post = PostRecord.create(
267267+ text: "This is my reply!",
268268+ reply: replyRef
269269+)
270270+try await repo.createPost(post, repo: myDID)
271271+```
272272+273273+### Quote Post
274274+275275+```swift
276276+let post = PostRecord(
277277+ text: "Check out this post!",
278278+ embed: .record(RecordEmbed(uri: quotedPostURI, cid: quotedPostCID))
279279+)
280280+try await repo.createPost(post, repo: myDID)
281281+```
282282+283283+### External Link Card
284284+285285+```swift
286286+let post = PostRecord(
287287+ text: "Great article",
288288+ embed: .external(ExternalEmbed(
289289+ uri: "https://example.com/article",
290290+ title: "Article Title",
291291+ description: "A brief description of the article"
292292+ ))
293293+)
294294+try await repo.createPost(post, repo: myDID)
295295+```
296296+297297+## Models
298298+299299+All models are `Codable`, `Sendable`, and most are `Identifiable` for SwiftUI compatibility.
300300+301301+### Key Types
302302+303303+| Type | Description |
304304+|------|-------------|
305305+| `Profile` | User profile with stats and viewer state |
306306+| `Timeline` | Home timeline with cursor |
307307+| `TimelineItem` | A post in the timeline (may include reply context) |
308308+| `Post` | Full post with author, record, embed, and stats |
309309+| `PostThread` | Thread view with replies |
310310+| `Notification` | Notification with reason and content |
311311+| `Feed` | Custom feed generator info |
312312+| `Follows` / `Followers` | Social graph lists |
313313+314314+### Notification Reasons
315315+316316+```swift
317317+public enum NotificationReason: String {
318318+ case like
319319+ case repost
320320+ case follow
321321+ case mention
322322+ case reply
323323+ case quote
324324+ case starterpackJoined
325325+}
326326+```
327327+328328+## Thread Safety
329329+330330+All services use `@APActor` for thread-safe access:
331331+332332+```swift
333333+@APActor
334334+func loadTimeline() async throws {
335335+ let service = BskyService() // Safe to create on APActor
336336+ let timeline = try await service.getTimeline()
337337+ // Process timeline...
338338+}
339339+```
340340+341341+## Error Handling
342342+343343+Errors are typed via `AtError` from CoreATProtocol:
344344+345345+```swift
346346+do {
347347+ let profile = try await service.getProfile(for: "nonexistent.handle")
348348+} catch let error as AtError {
349349+ switch error {
350350+ case .message(let msg):
351351+ // API error (e.g., "ProfileNotFound")
352352+ print("Error: \(msg.error) - \(msg.message ?? "")")
353353+ case .network(let networkError):
354354+ // Network/HTTP error
355355+ print("Network error: \(networkError)")
356356+ }
357357+}
358358+```
359359+360360+## Testing
361361+362362+bskyKit uses Swift Testing. Run tests with:
363363+364364+```bash
365365+swift test
366366+```
367367+368368+The test suite includes:
369369+- Rich text detection (links, mentions, hashtags)
370370+- Byte index conversion
371371+- Model decoding
372372+373373+## Related Packages
374374+375375+- **[CoreATProtocol](https://tangled.org/@sparrowtek.com/CoreATProtocol)** - Core networking layer (dependency)
376376+377377+## License
378378+379379+This project is licensed under an [MIT license](https://tangled.org/sparrowtek.com/bskyKit/blob/main/LICENSE).
380380+381381+## Contributing
382382+383383+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.
384384+385385+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
···11+//
22+// bskyAPI.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 10/11/25.
66+//
77+88+import Foundation
99+import CoreATProtocol
1010+1111+enum BskyAPI {
1212+ // Actor endpoints
1313+ case getPreferences
1414+ case getProfile(did: String)
1515+ case getProfiles(dids: [String])
1616+ case searchActors(query: String, limit: Int)
1717+ case searchActorsTypeahead(query: String, limit: Int)
1818+1919+ // Feed endpoints
2020+ case getFeedGenerators(feeds: [String])
2121+ case getTimeline(limit: Int, cursor: String?)
2222+ case getAuthorFeed(did: String, limit: Int, cursor: String?)
2323+ case getPostThread(uri: String, depth: Int)
2424+ case getPosts(uris: [String])
2525+ case getLikes(uri: String, limit: Int, cursor: String?)
2626+ case getRepostedBy(uri: String, limit: Int, cursor: String?)
2727+2828+ // Graph endpoints
2929+ case getFollows(did: String, limit: Int, cursor: String?)
3030+ case getFollowers(did: String, limit: Int, cursor: String?)
3131+ case getBlocks(limit: Int, cursor: String?)
3232+ case getMutes(limit: Int, cursor: String?)
3333+3434+ // Notification endpoints
3535+ case listNotifications(limit: Int, cursor: String?)
3636+ case getUnreadCount
3737+ case updateSeen(seenAt: Date)
3838+}
3939+4040+extension BskyAPI: EndpointType {
4141+ public var baseURL: URL {
4242+ get async {
4343+ guard let host = await APEnvironment.current.host else { fatalError("Host not set.") }
4444+ guard let url = URL(string: host) else { fatalError("BskyAPI baseURL not configured.") }
4545+ return url
4646+ }
4747+ }
4848+4949+ var path: String {
5050+ switch self {
5151+ // Actor
5252+ case .getPreferences: "/xrpc/app.bsky.actor.getPreferences"
5353+ case .getProfile: "/xrpc/app.bsky.actor.getProfile"
5454+ case .getProfiles: "/xrpc/app.bsky.actor.getProfiles"
5555+ case .searchActors: "/xrpc/app.bsky.actor.searchActors"
5656+ case .searchActorsTypeahead: "/xrpc/app.bsky.actor.searchActorsTypeahead"
5757+ // Feed
5858+ case .getFeedGenerators: "/xrpc/app.bsky.feed.getFeedGenerators"
5959+ case .getTimeline: "/xrpc/app.bsky.feed.getTimeline"
6060+ case .getAuthorFeed: "/xrpc/app.bsky.feed.getAuthorFeed"
6161+ case .getPostThread: "/xrpc/app.bsky.feed.getPostThread"
6262+ case .getPosts: "/xrpc/app.bsky.feed.getPosts"
6363+ case .getLikes: "/xrpc/app.bsky.feed.getLikes"
6464+ case .getRepostedBy: "/xrpc/app.bsky.feed.getRepostedBy"
6565+ // Graph
6666+ case .getFollows: "/xrpc/app.bsky.graph.getFollows"
6767+ case .getFollowers: "/xrpc/app.bsky.graph.getFollowers"
6868+ case .getBlocks: "/xrpc/app.bsky.graph.getBlocks"
6969+ case .getMutes: "/xrpc/app.bsky.graph.getMutes"
7070+ // Notifications
7171+ case .listNotifications: "/xrpc/app.bsky.notification.listNotifications"
7272+ case .getUnreadCount: "/xrpc/app.bsky.notification.getUnreadCount"
7373+ case .updateSeen: "/xrpc/app.bsky.notification.updateSeen"
7474+ }
7575+ }
7676+7777+ var httpMethod: HTTPMethod {
7878+ switch self {
7979+ case .getPreferences, .getProfile, .getProfiles, .searchActors, .searchActorsTypeahead,
8080+ .getFeedGenerators, .getTimeline, .getAuthorFeed, .getPostThread, .getPosts, .getLikes, .getRepostedBy,
8181+ .getFollows, .getFollowers, .getBlocks, .getMutes,
8282+ .listNotifications, .getUnreadCount:
8383+ return .get
8484+ case .updateSeen:
8585+ return .post
8686+ }
8787+ }
8888+8989+ var task: HTTPTask {
9090+ switch self {
9191+ // Actor endpoints
9292+ case .getPreferences, .getUnreadCount:
9393+ return .request
9494+9595+ case .getProfile(let did):
9696+ return .requestParameters(encoding: .urlEncoding(parameters: ["actor": did]))
9797+9898+ case .getProfiles(let dids):
9999+ return .requestParameters(encoding: .urlEncoding(parameters: ["actors": dids]))
100100+101101+ case .searchActors(let query, let limit):
102102+ return .requestParameters(encoding: .urlEncoding(parameters: [
103103+ "q": query,
104104+ "limit": limit
105105+ ]))
106106+107107+ case .searchActorsTypeahead(let query, let limit):
108108+ return .requestParameters(encoding: .urlEncoding(parameters: [
109109+ "q": query,
110110+ "limit": limit
111111+ ]))
112112+113113+ // Feed endpoints
114114+ case .getFeedGenerators(let feeds):
115115+ return .requestParameters(encoding: .urlEncoding(parameters: ["feeds": feeds]))
116116+117117+ case .getTimeline(let limit, let cursor):
118118+ var params: Parameters = ["limit": limit]
119119+ if let cursor { params["cursor"] = cursor }
120120+ return .requestParameters(encoding: .urlEncoding(parameters: params))
121121+122122+ case .getAuthorFeed(let did, let limit, let cursor):
123123+ var params: Parameters = ["actor": did, "limit": limit]
124124+ if let cursor { params["cursor"] = cursor }
125125+ return .requestParameters(encoding: .urlEncoding(parameters: params))
126126+127127+ case .getPostThread(let uri, let depth):
128128+ return .requestParameters(encoding: .urlEncoding(parameters: [
129129+ "uri": uri,
130130+ "depth": depth
131131+ ]))
132132+133133+ case .getPosts(let uris):
134134+ return .requestParameters(encoding: .urlEncoding(parameters: ["uris": uris]))
135135+136136+ case .getLikes(let uri, let limit, let cursor):
137137+ var params: Parameters = ["uri": uri, "limit": limit]
138138+ if let cursor { params["cursor"] = cursor }
139139+ return .requestParameters(encoding: .urlEncoding(parameters: params))
140140+141141+ case .getRepostedBy(let uri, let limit, let cursor):
142142+ var params: Parameters = ["uri": uri, "limit": limit]
143143+ if let cursor { params["cursor"] = cursor }
144144+ return .requestParameters(encoding: .urlEncoding(parameters: params))
145145+146146+ // Graph endpoints
147147+ case .getFollows(let did, let limit, let cursor):
148148+ var params: Parameters = ["actor": did, "limit": limit]
149149+ if let cursor { params["cursor"] = cursor }
150150+ return .requestParameters(encoding: .urlEncoding(parameters: params))
151151+152152+ case .getFollowers(let did, let limit, let cursor):
153153+ var params: Parameters = ["actor": did, "limit": limit]
154154+ if let cursor { params["cursor"] = cursor }
155155+ return .requestParameters(encoding: .urlEncoding(parameters: params))
156156+157157+ case .getBlocks(let limit, let cursor):
158158+ var params: Parameters = ["limit": limit]
159159+ if let cursor { params["cursor"] = cursor }
160160+ return .requestParameters(encoding: .urlEncoding(parameters: params))
161161+162162+ case .getMutes(let limit, let cursor):
163163+ var params: Parameters = ["limit": limit]
164164+ if let cursor { params["cursor"] = cursor }
165165+ return .requestParameters(encoding: .urlEncoding(parameters: params))
166166+167167+ // Notification endpoints
168168+ case .listNotifications(let limit, let cursor):
169169+ var params: Parameters = ["limit": limit]
170170+ if let cursor { params["cursor"] = cursor }
171171+ return .requestParameters(encoding: .urlEncoding(parameters: params))
172172+173173+ case .updateSeen(let seenAt):
174174+ let formatter = ISO8601DateFormatter()
175175+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
176176+ return .requestParameters(encoding: .jsonEncoding(parameters: [
177177+ "seenAt": formatter.string(from: seenAt)
178178+ ]))
179179+ }
180180+ }
181181+182182+ var headers: HTTPHeaders? {
183183+ nil
184184+ }
185185+}
+261
Sources/bskyKit/BskyService.swift
···11+//
22+// bskyService.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 10/11/25.
66+//
77+88+import Foundation
99+import CoreATProtocol
1010+1111+/// The main service for reading Bluesky social data.
1212+///
1313+/// `BskyService` provides methods for fetching profiles, timelines, feeds,
1414+/// social graph data, and notifications from the Bluesky network.
1515+///
1616+/// ## Overview
1717+///
1818+/// Use `BskyService` for all read operations. For write operations (creating posts,
1919+/// likes, follows, etc.), use ``RepoService`` instead.
2020+///
2121+/// ## Example
2222+///
2323+/// ```swift
2424+/// // Configure the environment first
2525+/// await setup(hostURL: "https://bsky.social", accessJWT: token, refreshJWT: nil)
2626+///
2727+/// // Create service and fetch data
2828+/// let service = await BskyService()
2929+/// let profile = try await service.getProfile(for: "alice.bsky.social")
3030+/// let timeline = try await service.getTimeline(limit: 20)
3131+/// ```
3232+///
3333+/// ## Topics
3434+///
3535+/// ### Actor Operations
3636+/// - ``getPreferences()``
3737+/// - ``getProfile(for:)``
3838+/// - ``getProfiles(for:)``
3939+/// - ``searchActors(query:limit:)``
4040+/// - ``searchActorsTypeahead(query:limit:)``
4141+///
4242+/// ### Feed Operations
4343+/// - ``getTimeline(limit:cursor:)``
4444+/// - ``getAuthorFeed(for:limit:cursor:)``
4545+/// - ``getPostThread(uri:depth:)``
4646+/// - ``getPosts(uris:)``
4747+/// - ``getFeedGenerators(for:)``
4848+/// - ``getLikes(uri:limit:cursor:)``
4949+/// - ``getRepostedBy(uri:limit:cursor:)``
5050+///
5151+/// ### Graph Operations
5252+/// - ``getFollows(for:limit:cursor:)``
5353+/// - ``getFollowers(for:limit:cursor:)``
5454+/// - ``getBlocks(limit:cursor:)``
5555+/// - ``getMutes(limit:cursor:)``
5656+///
5757+/// ### Notification Operations
5858+/// - ``listNotifications(limit:cursor:)``
5959+/// - ``getUnreadCount()``
6060+/// - ``updateSeen(at:)``
6161+@APActor
6262+public struct BskyService: Sendable {
6363+ private let router: NetworkRouter<BskyAPI> = {
6464+ let router = NetworkRouter<BskyAPI>(decoder: .atDecoder)
6565+ router.delegate = APEnvironment.current.routerDelegate
6666+ return router
6767+ }()
6868+6969+ /// Creates a new BskyService instance.
7070+ ///
7171+ /// Before using the service, ensure you've configured the environment with
7272+ /// `setup(hostURL:accessJWT:refreshJWT:)`.
7373+ public init() {}
7474+7575+ // MARK: - Actor
7676+7777+ /// Fetches the authenticated user's preferences.
7878+ /// - Returns: The user's saved preferences including pinned feeds.
7979+ /// - Throws: An error if the request fails or the user is not authenticated.
8080+ public func getPreferences() async throws -> Preferences {
8181+ try await router.execute(.getPreferences)
8282+ }
8383+8484+ /// Fetches a user profile by handle or DID.
8585+ /// - Parameter did: The user's handle (e.g., "alice.bsky.social") or DID.
8686+ /// - Returns: The user's profile.
8787+ /// - Throws: An error if the profile is not found or the request fails.
8888+ public func getProfile(for did: String) async throws -> Profile {
8989+ try await router.execute(.getProfile(did: did))
9090+ }
9191+9292+ /// Fetches multiple user profiles in a single request.
9393+ /// - Parameter dids: Array of handles or DIDs to fetch.
9494+ /// - Returns: The profiles for the requested users.
9595+ /// - Throws: An error if the request fails.
9696+ public func getProfiles(for dids: [String]) async throws -> Profiles {
9797+ try await router.execute(.getProfiles(dids: dids))
9898+ }
9999+100100+ /// Searches for users matching a query.
101101+ /// - Parameters:
102102+ /// - query: Search query string (searches handle, display name, and bio).
103103+ /// - limit: Maximum number of results to return (default: 25, max: 100).
104104+ /// - Returns: Matching user profiles with optional cursor for pagination.
105105+ /// - Throws: An error if the request fails.
106106+ public func searchActors(query: String, limit: Int = 25) async throws -> SearchActorsResult {
107107+ try await router.execute(.searchActors(query: query, limit: limit))
108108+ }
109109+110110+ /// Fast search for autocomplete functionality.
111111+ /// - Parameters:
112112+ /// - query: Search prefix for typeahead matching.
113113+ /// - limit: Maximum number of results (default: 10).
114114+ /// - Returns: Matching profiles optimized for autocomplete.
115115+ /// - Throws: An error if the request fails.
116116+ public func searchActorsTypeahead(query: String, limit: Int = 10) async throws -> SearchActorsTypeaheadResult {
117117+ try await router.execute(.searchActorsTypeahead(query: query, limit: limit))
118118+ }
119119+120120+ // MARK: - Feed
121121+122122+ /// Fetches information about custom feed generators.
123123+ /// - Parameter feeds: Array of feed generator AT-URIs.
124124+ /// - Returns: Details about the requested feed generators.
125125+ /// - Throws: An error if the request fails.
126126+ public func getFeedGenerators(for feeds: [String]) async throws -> Feeds {
127127+ try await router.execute(.getFeedGenerators(feeds: feeds))
128128+ }
129129+130130+ /// Fetches the authenticated user's home timeline.
131131+ /// - Parameters:
132132+ /// - limit: Maximum number of posts to return (default: 50, max: 100).
133133+ /// - cursor: Pagination cursor from a previous response.
134134+ /// - Returns: Timeline posts with cursor for pagination.
135135+ /// - Throws: An error if the user is not authenticated or the request fails.
136136+ public func getTimeline(limit: Int = 50, cursor: String? = nil) async throws -> Timeline {
137137+ try await router.execute(.getTimeline(limit: limit, cursor: cursor))
138138+ }
139139+140140+ /// Fetches posts from a specific user's feed.
141141+ /// - Parameters:
142142+ /// - did: The user's DID or handle.
143143+ /// - limit: Maximum number of posts to return (default: 50).
144144+ /// - cursor: Pagination cursor from a previous response.
145145+ /// - Returns: The user's posts with cursor for pagination.
146146+ /// - Throws: An error if the request fails.
147147+ public func getAuthorFeed(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed {
148148+ try await router.execute(.getAuthorFeed(did: did, limit: limit, cursor: cursor))
149149+ }
150150+151151+ /// Fetches a post and its reply thread.
152152+ /// - Parameters:
153153+ /// - uri: The AT-URI of the post.
154154+ /// - depth: How many levels of replies to fetch (default: 6).
155155+ /// - Returns: The post thread with nested replies.
156156+ /// - Throws: An error if the post is not found or the request fails.
157157+ public func getPostThread(uri: String, depth: Int = 6) async throws -> PostThreadResponse {
158158+ try await router.execute(.getPostThread(uri: uri, depth: depth))
159159+ }
160160+161161+ /// Fetches multiple posts by URI in a single request.
162162+ /// - Parameter uris: Array of post AT-URIs to fetch.
163163+ /// - Returns: The requested posts.
164164+ /// - Throws: An error if the request fails.
165165+ public func getPosts(uris: [String]) async throws -> Posts {
166166+ try await router.execute(.getPosts(uris: uris))
167167+ }
168168+169169+ /// Fetches users who liked a specific post.
170170+ /// - Parameters:
171171+ /// - uri: The AT-URI of the post.
172172+ /// - limit: Maximum number of likes to return (default: 50).
173173+ /// - cursor: Pagination cursor from a previous response.
174174+ /// - Returns: Users who liked the post with cursor for pagination.
175175+ /// - Throws: An error if the request fails.
176176+ public func getLikes(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> Likes {
177177+ try await router.execute(.getLikes(uri: uri, limit: limit, cursor: cursor))
178178+ }
179179+180180+ /// Fetches users who reposted a specific post.
181181+ /// - Parameters:
182182+ /// - uri: The AT-URI of the post.
183183+ /// - limit: Maximum number of reposts to return (default: 50).
184184+ /// - cursor: Pagination cursor from a previous response.
185185+ /// - Returns: Users who reposted with cursor for pagination.
186186+ /// - Throws: An error if the request fails.
187187+ public func getRepostedBy(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> RepostedBy {
188188+ try await router.execute(.getRepostedBy(uri: uri, limit: limit, cursor: cursor))
189189+ }
190190+191191+ // MARK: - Graph
192192+193193+ /// Fetches the list of users that a specific user follows.
194194+ /// - Parameters:
195195+ /// - did: The DID or handle of the user.
196196+ /// - limit: Maximum number of follows to return (default: 50).
197197+ /// - cursor: Pagination cursor from a previous response.
198198+ /// - Returns: Users being followed with cursor for pagination.
199199+ /// - Throws: An error if the request fails.
200200+ public func getFollows(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> Follows {
201201+ try await router.execute(.getFollows(did: did, limit: limit, cursor: cursor))
202202+ }
203203+204204+ /// Fetches the list of users following a specific user.
205205+ /// - Parameters:
206206+ /// - did: The DID or handle of the user.
207207+ /// - limit: Maximum number of followers to return (default: 50).
208208+ /// - cursor: Pagination cursor from a previous response.
209209+ /// - Returns: Followers with cursor for pagination.
210210+ /// - Throws: An error if the request fails.
211211+ public func getFollowers(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> Followers {
212212+ try await router.execute(.getFollowers(did: did, limit: limit, cursor: cursor))
213213+ }
214214+215215+ /// Fetches the authenticated user's blocked accounts.
216216+ /// - Parameters:
217217+ /// - limit: Maximum number of blocks to return (default: 50).
218218+ /// - cursor: Pagination cursor from a previous response.
219219+ /// - Returns: Blocked profiles with cursor for pagination.
220220+ /// - Throws: An error if not authenticated or the request fails.
221221+ public func getBlocks(limit: Int = 50, cursor: String? = nil) async throws -> Blocks {
222222+ try await router.execute(.getBlocks(limit: limit, cursor: cursor))
223223+ }
224224+225225+ /// Fetches the authenticated user's muted accounts.
226226+ /// - Parameters:
227227+ /// - limit: Maximum number of mutes to return (default: 50).
228228+ /// - cursor: Pagination cursor from a previous response.
229229+ /// - Returns: Muted profiles with cursor for pagination.
230230+ /// - Throws: An error if not authenticated or the request fails.
231231+ public func getMutes(limit: Int = 50, cursor: String? = nil) async throws -> Mutes {
232232+ try await router.execute(.getMutes(limit: limit, cursor: cursor))
233233+ }
234234+235235+ // MARK: - Notifications
236236+237237+ /// Fetches the authenticated user's notifications.
238238+ /// - Parameters:
239239+ /// - limit: Maximum number of notifications to return (default: 50).
240240+ /// - cursor: Pagination cursor from a previous response.
241241+ /// - Returns: Notifications with cursor for pagination.
242242+ /// - Throws: An error if not authenticated or the request fails.
243243+ public func listNotifications(limit: Int = 50, cursor: String? = nil) async throws -> NotificationsResponse {
244244+ try await router.execute(.listNotifications(limit: limit, cursor: cursor))
245245+ }
246246+247247+ /// Fetches the count of unread notifications.
248248+ /// - Returns: The number of unread notifications.
249249+ /// - Throws: An error if not authenticated or the request fails.
250250+ public func getUnreadCount() async throws -> UnreadCount {
251251+ try await router.execute(.getUnreadCount)
252252+ }
253253+254254+ /// Marks notifications as seen up to the specified time.
255255+ /// - Parameter date: The timestamp to mark as seen (default: now).
256256+ /// - Throws: An error if not authenticated or the request fails.
257257+ public func updateSeen(at date: Date = Date()) async throws {
258258+ let _: EmptyResponse = try await router.execute(.updateSeen(seenAt: date))
259259+ }
260260+}
261261+
···11+# Getting Started with bskyKit
22+33+Learn how to integrate bskyKit into your Swift project and make your first API calls.
44+55+## Overview
66+77+This guide walks you through setting up bskyKit, authenticating with Bluesky, and performing common operations like fetching profiles and reading timelines.
88+99+## Adding bskyKit to Your Project
1010+1111+Add bskyKit as a dependency in your `Package.swift`:
1212+1313+```swift
1414+dependencies: [
1515+ .package(path: "../bskyKit"),
1616+ // Or from a remote URL:
1717+ // .package(url: "https://your-repo/bskyKit", branch: "main"),
1818+]
1919+```
2020+2121+Then add it to your target:
2222+2323+```swift
2424+.target(
2525+ name: "YourApp",
2626+ dependencies: ["bskyKit"]
2727+)
2828+```
2929+3030+## Configuration
3131+3232+Before making API calls, configure the CoreATProtocol environment with your host and authentication tokens:
3333+3434+```swift
3535+import bskyKit
3636+import CoreATProtocol
3737+3838+@main
3939+struct MyApp {
4040+ static func main() async throws {
4141+ // Configure the environment
4242+ await setup(
4343+ hostURL: "https://bsky.social",
4444+ accessJWT: "your-access-token",
4545+ refreshJWT: "your-refresh-token"
4646+ )
4747+4848+ // Now you can use bskyKit services
4949+ let service = await BskyService()
5050+ // ...
5151+ }
5252+}
5353+```
5454+5555+> Important: Never hardcode tokens in your source code. Use secure storage like Keychain for production apps.
5656+5757+## Getting Authentication Tokens
5858+5959+To obtain authentication tokens, you need to authenticate with the Bluesky PDS. Here's a simplified example using the `com.atproto.server.createSession` endpoint:
6060+6161+```swift
6262+// This is a simplified example - use proper OAuth flow in production
6363+let credentials = [
6464+ "identifier": "your.handle.bsky.social",
6565+ "password": "your-app-password"
6666+]
6767+6868+// POST to https://bsky.social/xrpc/com.atproto.server.createSession
6969+// Response includes accessJwt and refreshJwt
7070+```
7171+7272+For production apps, implement proper OAuth 2.0 authentication with DPoP.
7373+7474+## Fetching Your First Profile
7575+7676+Once configured, you can fetch user profiles:
7777+7878+```swift
7979+import bskyKit
8080+import CoreATProtocol
8181+8282+func fetchProfile() async throws {
8383+ let service = await BskyService()
8484+8585+ // Fetch by handle or DID
8686+ let profile = try await service.getProfile(for: "alice.bsky.social")
8787+8888+ print("Handle: @\(profile.handle)")
8989+ print("Display Name: \(profile.displayName ?? "N/A")")
9090+ print("Followers: \(profile.followersCount ?? 0)")
9191+ print("Following: \(profile.followsCount ?? 0)")
9292+ print("Posts: \(profile.postsCount ?? 0)")
9393+9494+ if let bio = profile.description {
9595+ print("Bio: \(bio)")
9696+ }
9797+}
9898+```
9999+100100+## Reading the Timeline
101101+102102+Fetch the authenticated user's home timeline:
103103+104104+```swift
105105+func readTimeline() async throws {
106106+ let service = await BskyService()
107107+108108+ // Fetch 20 posts
109109+ let timeline = try await service.getTimeline(limit: 20)
110110+111111+ for item in timeline.feed {
112112+ let post = item.post
113113+ print("@\(post.author.handle): \(post.record.text)")
114114+ print(" Likes: \(post.likeCount) | Reposts: \(post.repostCount)")
115115+ print("")
116116+ }
117117+118118+ // Use cursor for pagination
119119+ if !timeline.cursor.isEmpty {
120120+ let nextPage = try await service.getTimeline(limit: 20, cursor: timeline.cursor)
121121+ // Process next page...
122122+ }
123123+}
124124+```
125125+126126+## Searching for Users
127127+128128+Search for users by name or handle:
129129+130130+```swift
131131+func searchUsers(query: String) async throws {
132132+ let service = await BskyService()
133133+134134+ let results = try await service.searchActors(query: query, limit: 10)
135135+136136+ for actor in results.actors {
137137+ print("@\(actor.handle)")
138138+ if let name = actor.displayName {
139139+ print(" Name: \(name)")
140140+ }
141141+ if let bio = actor.description {
142142+ print(" Bio: \(String(bio.prefix(100)))...")
143143+ }
144144+ }
145145+}
146146+```
147147+148148+## Error Handling
149149+150150+bskyKit uses Swift's native error handling. Common errors include:
151151+152152+```swift
153153+import CoreATProtocol
154154+155155+func fetchWithErrorHandling() async {
156156+ let service = await BskyService()
157157+158158+ do {
159159+ let profile = try await service.getProfile(for: "nonexistent.user")
160160+ } catch let error as AtError {
161161+ switch error {
162162+ case .message(let msg):
163163+ print("API Error: \(msg.error) - \(msg.message ?? "")")
164164+ case .network(let networkError):
165165+ print("Network Error: \(networkError)")
166166+ }
167167+ } catch {
168168+ print("Unexpected error: \(error)")
169169+ }
170170+}
171171+```
172172+173173+## Next Steps
174174+175175+Now that you've made your first API calls, explore these topics:
176176+177177+- <doc:WorkingWithProfiles> - Deep dive into profile operations
178178+- <doc:TimelineAndFeeds> - Working with timelines and custom feeds
179179+- <doc:RichTextGuide> - Creating posts with mentions, links, and hashtags
180180+- <doc:SocialActions> - Liking, reposting, and following
···11+# Notifications
22+33+List, read, and manage notifications.
44+55+## Overview
66+77+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.
88+99+## Listing Notifications
1010+1111+Use ``BskyService/listNotifications(limit:cursor:)`` to fetch notifications:
1212+1313+```swift
1414+let service = await BskyService()
1515+1616+let response = try await service.listNotifications(limit: 50)
1717+1818+for notification in response.notifications {
1919+ print("[\(notification.reason.rawValue)] @\(notification.author.handle)")
2020+2121+ switch notification.reason {
2222+ case .like:
2323+ print(" liked your post")
2424+ case .repost:
2525+ print(" reposted your post")
2626+ case .follow:
2727+ print(" followed you")
2828+ case .mention:
2929+ print(" mentioned you")
3030+ case .reply:
3131+ print(" replied to you")
3232+ if let text = notification.record?.text {
3333+ print(" \"\(text)\"")
3434+ }
3535+ case .quote:
3636+ print(" quoted your post")
3737+ case .starterpackJoined:
3838+ print(" joined via your starter pack")
3939+ }
4040+4141+ print(" Read: \(notification.isRead)")
4242+ print("")
4343+}
4444+```
4545+4646+## Pagination
4747+4848+Paginate through notifications with cursors:
4949+5050+```swift
5151+var allNotifications: [Notification] = []
5252+var cursor: String? = nil
5353+5454+repeat {
5555+ let response = try await service.listNotifications(
5656+ limit: 100,
5757+ cursor: cursor
5858+ )
5959+ allNotifications.append(contentsOf: response.notifications)
6060+ cursor = response.cursor
6161+} while cursor != nil
6262+6363+print("Total notifications: \(allNotifications.count)")
6464+```
6565+6666+## Unread Count
6767+6868+Check the number of unread notifications:
6969+7070+```swift
7171+let unread = try await service.getUnreadCount()
7272+print("You have \(unread.count) unread notifications")
7373+```
7474+7575+## Mark as Read
7676+7777+Mark all notifications as seen up to the current time:
7878+7979+```swift
8080+try await service.updateSeen()
8181+print("Notifications marked as read")
8282+8383+// Or mark as seen at a specific time
8484+try await service.updateSeen(at: Date())
8585+```
8686+8787+## Filtering Notifications
8888+8989+Filter notifications by type:
9090+9191+```swift
9292+let response = try await service.listNotifications(limit: 100)
9393+9494+// Only likes
9595+let likes = response.notifications.filter { $0.reason == .like }
9696+print("Likes: \(likes.count)")
9797+9898+// Only follows
9999+let follows = response.notifications.filter { $0.reason == .follow }
100100+print("New followers: \(follows.count)")
101101+102102+// Only interactions (likes, reposts, quotes)
103103+let interactions = response.notifications.filter {
104104+ [.like, .repost, .quote].contains($0.reason)
105105+}
106106+print("Interactions: \(interactions.count)")
107107+108108+// Only conversations (mentions, replies)
109109+let conversations = response.notifications.filter {
110110+ [.mention, .reply].contains($0.reason)
111111+}
112112+print("Conversations: \(conversations.count)")
113113+```
114114+115115+## Notification Reasons
116116+117117+The ``NotificationReason`` enum defines all notification types:
118118+119119+```swift
120120+public enum NotificationReason: String, Codable, Sendable {
121121+ case like // Someone liked your post
122122+ case repost // Someone reposted your post
123123+ case follow // Someone followed you
124124+ case mention // Someone mentioned you in a post
125125+ case reply // Someone replied to your post
126126+ case quote // Someone quoted your post
127127+ case starterpackJoined = "starterpack-joined" // Someone joined via your starter pack
128128+}
129129+```
130130+131131+## Model Reference
132132+133133+### NotificationsResponse
134134+135135+```swift
136136+public struct NotificationsResponse: Codable, Sendable {
137137+ public let notifications: [Notification]
138138+ public let cursor: String?
139139+ public let seenAt: Date?
140140+}
141141+```
142142+143143+### Notification
144144+145145+```swift
146146+public struct Notification: Codable, Sendable, Identifiable {
147147+ public let uri: String
148148+ public let cid: String
149149+ public let author: NotificationAuthor
150150+ public let reason: NotificationReason
151151+ public let reasonSubject: String? // URI of the post that was interacted with
152152+ public let record: NotificationRecord? // Content for mentions/replies
153153+ public let isRead: Bool
154154+ public let indexedAt: Date
155155+156156+ public var id: String { uri }
157157+}
158158+```
159159+160160+### NotificationAuthor
161161+162162+```swift
163163+public struct NotificationAuthor: Codable, Sendable, Identifiable {
164164+ public let did: String
165165+ public let handle: String
166166+ public let displayName: String?
167167+ public let avatar: String?
168168+ public let viewer: Viewer?
169169+ public let labels: [AuthorLabels]?
170170+171171+ public var id: String { did }
172172+}
173173+```
174174+175175+### NotificationRecord
176176+177177+For mentions and replies, contains the post content:
178178+179179+```swift
180180+public struct NotificationRecord: Codable, Sendable {
181181+ public let type: String?
182182+ public let text: String?
183183+ public let createdAt: Date?
184184+}
185185+```
186186+187187+### UnreadCount
188188+189189+```swift
190190+public struct UnreadCount: Codable, Sendable {
191191+ public let count: Int
192192+}
193193+```
194194+195195+## Common Patterns
196196+197197+### Polling for New Notifications
198198+199199+```swift
200200+func checkForNewNotifications() async {
201201+ let unread = try? await service.getUnreadCount()
202202+ if let count = unread?.count, count > 0 {
203203+ print("You have \(count) new notifications!")
204204+205205+ // Optionally fetch and display them
206206+ let notifications = try? await service.listNotifications(limit: count)
207207+ // Process new notifications...
208208+ }
209209+}
210210+```
211211+212212+### Building a Notification Badge
213213+214214+```swift
215215+@Observable
216216+class NotificationManager {
217217+ var unreadCount: Int = 0
218218+219219+ func refresh() async {
220220+ if let count = try? await service.getUnreadCount() {
221221+ unreadCount = count.count
222222+ }
223223+ }
224224+225225+ func markAllRead() async {
226226+ try? await service.updateSeen()
227227+ unreadCount = 0
228228+ }
229229+}
230230+```
231231+232232+### Grouping Notifications
233233+234234+```swift
235235+func groupNotifications(_ notifications: [Notification]) -> [String: [Notification]] {
236236+ Dictionary(grouping: notifications) { notification in
237237+ notification.reason.rawValue
238238+ }
239239+}
240240+241241+let grouped = groupNotifications(response.notifications)
242242+for (reason, items) in grouped {
243243+ print("\(reason): \(items.count)")
244244+}
245245+```
246246+247247+## See Also
248248+249249+- ``BskyService``
250250+- ``NotificationsResponse``
251251+- ``Notification``
252252+- ``NotificationReason``
253253+- ``NotificationAuthor``
254254+- ``UnreadCount``
···11+# Rich Text and Facets
22+33+Create posts with clickable mentions, links, and hashtags.
44+55+## Overview
66+77+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.
88+99+## Understanding Facets
1010+1111+Facets are annotations that mark segments of text with special meaning:
1212+1313+- **Mentions**: `@handle` - Links to a user profile
1414+- **Links**: `https://...` - Clickable URLs
1515+- **Tags**: `#hashtag` - Searchable hashtags
1616+1717+Each facet specifies:
1818+- `byteStart`: Starting byte position in UTF-8 encoded text
1919+- `byteEnd`: Ending byte position
2020+- `features`: Array of feature types (link, mention, or tag)
2121+2222+> Important: Facets use **byte indices**, not character indices. This matters for text containing emoji or non-ASCII characters.
2323+2424+## Auto-Detecting Facets
2525+2626+The easiest way to create rich text is with automatic detection:
2727+2828+```swift
2929+let text = "Hey @alice.bsky.social check out https://example.com #atproto"
3030+3131+let richText = RichText.detect(in: text)
3232+3333+print("Text: \(richText.text)")
3434+print("Facets found: \(richText.facets.count)")
3535+3636+for facet in richText.facets {
3737+ print(" Bytes \(facet.index.byteStart)-\(facet.index.byteEnd)")
3838+ for feature in facet.features {
3939+ switch feature {
4040+ case .mention(let mention):
4141+ print(" Mention: @\(mention.handle ?? "")")
4242+ case .link(let link):
4343+ print(" Link: \(link.uri)")
4444+ case .tag(let tag):
4545+ print(" Tag: #\(tag.tag)")
4646+ }
4747+ }
4848+}
4949+```
5050+5151+Output:
5252+```
5353+Text: Hey @alice.bsky.social check out https://example.com #atproto
5454+Facets found: 3
5555+ Bytes 4-23
5656+ Mention: @alice.bsky.social
5757+ Bytes 35-54
5858+ Link: https://example.com
5959+ Bytes 55-63
6060+ Tag: #atproto
6161+```
6262+6363+## Creating Posts with Rich Text
6464+6565+Use ``PostRecord/create(text:reply:embed:langs:)`` to create posts with auto-detected facets:
6666+6767+```swift
6868+let repoService = await RepoService()
6969+7070+// Create post with auto-detected facets
7171+let post = PostRecord.create(
7272+ text: "Hello @friend.bsky.social! Check out https://swift.org #SwiftLang"
7373+)
7474+7575+let response = try await repoService.createPost(post, repo: myDID)
7676+print("Created post: \(response.uri)")
7777+```
7878+7979+## Manual Facet Creation
8080+8181+For precise control, create facets manually:
8282+8383+```swift
8484+let text = "Visit my website"
8585+8686+let facet = RichTextFacet(
8787+ index: RichTextFacetIndex(byteStart: 6, byteEnd: 16),
8888+ features: [.link(RichTextLink(uri: "https://example.com"))]
8989+)
9090+9191+let richText = RichText(text: text, facets: [facet])
9292+9393+let post = PostRecord(
9494+ text: richText.text,
9595+ facets: richText.facets
9696+)
9797+```
9898+9999+## Byte Index Conversion
100100+101101+When working with user-selected ranges, convert between character and byte indices:
102102+103103+```swift
104104+let text = "Hello 👋 World"
105105+let richText = RichText(text: text)
106106+107107+// Character index to byte index
108108+let charIndex = text.index(text.startIndex, offsetBy: 8) // 'W' in "World"
109109+let byteIndex = richText.byteIndex(from: charIndex)
110110+print("Byte index: \(byteIndex)") // 11 (emoji takes 4 bytes)
111111+112112+// Byte index to character index
113113+if let charIdx = richText.characterIndex(from: 11) {
114114+ print("Character: \(text[charIdx])") // W
115115+}
116116+```
117117+118118+## Facet Types Reference
119119+120120+### RichTextFacet
121121+122122+```swift
123123+public struct RichTextFacet: Codable, Sendable {
124124+ public let index: RichTextFacetIndex
125125+ public let features: [RichTextFeature]
126126+}
127127+```
128128+129129+### RichTextFacetIndex
130130+131131+```swift
132132+public struct RichTextFacetIndex: Codable, Sendable {
133133+ public let byteStart: Int
134134+ public let byteEnd: Int
135135+}
136136+```
137137+138138+### RichTextFeature
139139+140140+```swift
141141+public enum RichTextFeature: Codable, Sendable {
142142+ case link(RichTextLink)
143143+ case mention(RichTextMention)
144144+ case tag(RichTextTag)
145145+}
146146+```
147147+148148+### Feature Types
149149+150150+```swift
151151+public struct RichTextLink: Codable, Sendable {
152152+ public let uri: String
153153+}
154154+155155+public struct RichTextMention: Codable, Sendable {
156156+ public let handle: String? // Before resolution
157157+ public let did: String? // After resolution
158158+}
159159+160160+public struct RichTextTag: Codable, Sendable {
161161+ public let tag: String
162162+}
163163+```
164164+165165+## Detection Rules
166166+167167+### Mentions
168168+- Must start with `@`
169169+- Can contain letters, numbers, dots, hyphens, underscores
170170+- Examples: `@alice`, `@bob.bsky.social`, `@did:plc:abc123`
171171+172172+### Links
173173+- Detected using NSDataDetector
174174+- Must be valid URLs
175175+- Examples: `https://example.com`, `http://localhost:8080`
176176+177177+### Hashtags
178178+- Must start with `#`
179179+- Cannot start with a number
180180+- Can contain letters, numbers, underscores
181181+- Examples: `#Swift`, `#iOS_dev`, `#2024goals` (not detected - starts with number)
182182+183183+## Converting for API
184184+185185+When creating records, convert facets to the API format:
186186+187187+```swift
188188+let richText = RichText.detect(in: text)
189189+let apiFacets = richText.toAPIFacets()
190190+// Returns [[String: Any]] suitable for JSON encoding
191191+```
192192+193193+## Working with Emoji and Unicode
194194+195195+Emoji and non-ASCII characters require special handling because they occupy multiple bytes in UTF-8:
196196+197197+```swift
198198+let text = "Love this! 🎉"
199199+let richText = RichText.detect(in: text)
200200+201201+// "🎉" is 4 bytes in UTF-8
202202+// Character count: 12
203203+// Byte count: 15
204204+```
205205+206206+Always use ``RichText/byteIndex(from:)`` when converting from String indices.
207207+208208+## Best Practices
209209+210210+1. **Use auto-detection**: Let ``RichText/detect(in:)`` handle facet creation
211211+2. **Verify byte indices**: Test with emoji-heavy text to ensure correctness
212212+3. **Handle missing DIDs**: Detected mentions have `handle` but not `did` - resolve DIDs before posting if needed
213213+4. **Sort facets**: Facets should be sorted by `byteStart` (auto-detection does this)
214214+215215+## See Also
216216+217217+- ``RichText``
218218+- ``RichTextFacet``
219219+- ``RichTextFeature``
220220+- ``PostRecord``
221221+- <doc:SocialActions>
···11+# Social Actions
22+33+Create posts, likes, reposts, and manage social interactions.
44+55+## Overview
66+77+bskyKit's ``RepoService`` provides methods for all write operations on Bluesky. This includes creating posts, liking and reposting content, following users, and more.
88+99+## Creating Posts
1010+1111+### Simple Post
1212+1313+Create a basic text post:
1414+1515+```swift
1616+let repoService = await RepoService()
1717+let myDID = "did:plc:your-did-here"
1818+1919+// Simple post
2020+let post = PostRecord(text: "Hello, Bluesky!")
2121+let response = try await repoService.createPost(post, repo: myDID)
2222+2323+print("Post created: \(response.uri)")
2424+print("CID: \(response.cid)")
2525+```
2626+2727+### Post with Auto-Detected Facets
2828+2929+Include mentions, links, and hashtags:
3030+3131+```swift
3232+let post = PostRecord.create(
3333+ text: "Hey @alice.bsky.social! Check out https://swift.org #SwiftLang"
3434+)
3535+3636+let response = try await repoService.createPost(post, repo: myDID)
3737+```
3838+3939+### Reply to a Post
4040+4141+Reply to an existing post:
4242+4343+```swift
4444+let parentPost = PostRef(
4545+ uri: "at://did:plc:abc/app.bsky.feed.post/123",
4646+ cid: "bafyrei..."
4747+)
4848+4949+// For top-level replies, root and parent are the same
5050+let reply = ReplyRef(root: parentPost, parent: parentPost)
5151+5252+let post = PostRecord.create(
5353+ text: "Great point! I agree completely.",
5454+ reply: reply
5555+)
5656+5757+let response = try await repoService.createPost(post, repo: myDID)
5858+```
5959+6060+### Post with Quote
6161+6262+Quote another post:
6363+6464+```swift
6565+let quotedPost = RecordEmbed(
6666+ uri: "at://did:plc:abc/app.bsky.feed.post/123",
6767+ cid: "bafyrei..."
6868+)
6969+7070+let post = PostRecord.create(
7171+ text: "This is so true!",
7272+ embed: .record(quotedPost)
7373+)
7474+7575+let response = try await repoService.createPost(post, repo: myDID)
7676+```
7777+7878+### Post with External Link
7979+8080+Add a link card:
8181+8282+```swift
8383+let linkEmbed = ExternalEmbed(
8484+ uri: "https://swift.org",
8585+ title: "Swift.org",
8686+ description: "Swift is a general-purpose programming language..."
8787+)
8888+8989+let post = PostRecord.create(
9090+ text: "Check out the Swift programming language",
9191+ embed: .external(linkEmbed)
9292+)
9393+9494+let response = try await repoService.createPost(post, repo: myDID)
9595+```
9696+9797+## Liking Posts
9898+9999+Like a post:
100100+101101+```swift
102102+let likeResponse = try await repoService.like(
103103+ uri: "at://did:plc:abc/app.bsky.feed.post/123",
104104+ cid: "bafyrei...",
105105+ repo: myDID
106106+)
107107+108108+print("Like created: \(likeResponse.uri)")
109109+```
110110+111111+Unlike a post:
112112+113113+```swift
114114+// Use the URI from the like record (viewer.like from the post)
115115+try await repoService.unlike(
116116+ uri: "at://did:plc:mydid/app.bsky.feed.like/xyz",
117117+ repo: myDID
118118+)
119119+```
120120+121121+## Reposting
122122+123123+Repost a post:
124124+125125+```swift
126126+let repostResponse = try await repoService.repost(
127127+ uri: "at://did:plc:abc/app.bsky.feed.post/123",
128128+ cid: "bafyrei...",
129129+ repo: myDID
130130+)
131131+132132+print("Repost created: \(repostResponse.uri)")
133133+```
134134+135135+Remove a repost:
136136+137137+```swift
138138+try await repoService.unrepost(
139139+ uri: "at://did:plc:mydid/app.bsky.feed.repost/xyz",
140140+ repo: myDID
141141+)
142142+```
143143+144144+## Following Users
145145+146146+Follow a user:
147147+148148+```swift
149149+let followResponse = try await repoService.follow(
150150+ did: "did:plc:user-to-follow",
151151+ repo: myDID
152152+)
153153+154154+print("Follow created: \(followResponse.uri)")
155155+```
156156+157157+Unfollow a user:
158158+159159+```swift
160160+// Use the URI from viewer.following
161161+try await repoService.unfollow(
162162+ uri: "at://did:plc:mydid/app.bsky.graph.follow/xyz",
163163+ repo: myDID
164164+)
165165+```
166166+167167+## Blocking Users
168168+169169+Block a user:
170170+171171+```swift
172172+let blockResponse = try await repoService.block(
173173+ did: "did:plc:user-to-block",
174174+ repo: myDID
175175+)
176176+```
177177+178178+Unblock:
179179+180180+```swift
181181+try await repoService.unblock(
182182+ uri: "at://did:plc:mydid/app.bsky.graph.block/xyz",
183183+ repo: myDID
184184+)
185185+```
186186+187187+## Low-Level Record Operations
188188+189189+For advanced use cases, use the generic record methods:
190190+191191+### Create Any Record
192192+193193+```swift
194194+let record: [String: Any] = [
195195+ "$type": "app.bsky.feed.post",
196196+ "text": "Hello world",
197197+ "createdAt": ISO8601DateFormatter().string(from: Date())
198198+]
199199+200200+let response = try await repoService.createRecord(
201201+ repo: myDID,
202202+ collection: "app.bsky.feed.post",
203203+ record: record
204204+)
205205+```
206206+207207+### Delete Any Record
208208+209209+```swift
210210+try await repoService.deleteRecord(
211211+ repo: myDID,
212212+ collection: "app.bsky.feed.post",
213213+ rkey: "abc123"
214214+)
215215+```
216216+217217+### Get a Record
218218+219219+```swift
220220+let record = try await repoService.getRecord(
221221+ repo: "did:plc:abc",
222222+ collection: "app.bsky.feed.post",
223223+ rkey: "xyz789"
224224+)
225225+226226+print("Text: \(record.value.text ?? "")")
227227+```
228228+229229+### List Records
230230+231231+```swift
232232+let records = try await repoService.listRecords(
233233+ repo: myDID,
234234+ collection: "app.bsky.feed.post",
235235+ limit: 100
236236+)
237237+238238+for item in records.records {
239239+ print("\(item.uri): \(item.value.text ?? "")")
240240+}
241241+```
242242+243243+## PostRecord Reference
244244+245245+```swift
246246+public struct PostRecord: Sendable {
247247+ public let text: String
248248+ public let facets: [RichTextFacet]?
249249+ public let reply: ReplyRef?
250250+ public let embed: PostEmbed?
251251+ public let langs: [String]?
252252+ public let createdAt: Date
253253+254254+ // Create with auto-detected facets
255255+ public static func create(
256256+ text: String,
257257+ reply: ReplyRef? = nil,
258258+ embed: PostEmbed? = nil,
259259+ langs: [String]? = nil
260260+ ) -> PostRecord
261261+}
262262+```
263263+264264+## Embed Types
265265+266266+```swift
267267+public enum PostEmbed: Sendable {
268268+ case images([ImageEmbed]) // Up to 4 images
269269+ case external(ExternalEmbed) // Link card
270270+ case record(RecordEmbed) // Quote post
271271+ case recordWithMedia(RecordEmbed, [ImageEmbed]) // Quote with images
272272+}
273273+```
274274+275275+## Error Handling
276276+277277+```swift
278278+do {
279279+ let response = try await repoService.createPost(post, repo: myDID)
280280+} catch RepoError.invalidUri(let uri) {
281281+ print("Invalid URI: \(uri)")
282282+} catch let error as AtError {
283283+ switch error {
284284+ case .message(let msg):
285285+ print("API error: \(msg.error)")
286286+ case .network(let networkError):
287287+ print("Network error: \(networkError)")
288288+ }
289289+}
290290+```
291291+292292+## Best Practices
293293+294294+1. **Always use your own DID** for the `repo` parameter
295295+2. **Store record URIs** from responses - you'll need them for unlike/unrepost/unfollow
296296+3. **Use PostRecord.create()** for automatic facet detection
297297+4. **Handle errors** - network issues and API errors are common
298298+5. **Rate limit** your requests - don't spam the API
299299+300300+## See Also
301301+302302+- ``RepoService``
303303+- ``PostRecord``
304304+- ``ReplyRef``
305305+- ``PostEmbed``
306306+- <doc:RichTextGuide>
+305
Sources/bskyKit/Documentation.docc/SocialGraph.md
···11+# Social Graph
22+33+Manage follows, followers, blocks, and mutes.
44+55+## Overview
66+77+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.
88+99+## Follows
1010+1111+### Get Who a User Follows
1212+1313+Use ``BskyService/getFollows(for:limit:cursor:)`` to see who a user follows:
1414+1515+```swift
1616+let service = await BskyService()
1717+1818+let follows = try await service.getFollows(
1919+ for: "did:plc:abc123",
2020+ limit: 50
2121+)
2222+2323+print("\(follows.subject.handle) follows \(follows.follows.count) users:")
2424+2525+for follow in follows.follows {
2626+ print(" @\(follow.handle)")
2727+ if let displayName = follow.displayName {
2828+ print(" \(displayName)")
2929+ }
3030+}
3131+3232+// Paginate with cursor
3333+if let cursor = follows.cursor {
3434+ let nextPage = try await service.getFollows(
3535+ for: "did:plc:abc123",
3636+ limit: 50,
3737+ cursor: cursor
3838+ )
3939+}
4040+```
4141+4242+### Get a User's Followers
4343+4444+Use ``BskyService/getFollowers(for:limit:cursor:)`` to see who follows a user:
4545+4646+```swift
4747+let followers = try await service.getFollowers(
4848+ for: "did:plc:abc123",
4949+ limit: 50
5050+)
5151+5252+print("\(followers.subject.handle) has \(followers.followers.count) followers:")
5353+5454+for follower in followers.followers {
5555+ print(" @\(follower.handle)")
5656+}
5757+```
5858+5959+### Follow a User
6060+6161+Use ``RepoService/follow(did:repo:)`` to follow someone:
6262+6363+```swift
6464+let repoService = await RepoService()
6565+6666+let response = try await repoService.follow(
6767+ did: "did:plc:user-to-follow",
6868+ repo: myDID
6969+)
7070+7171+print("Follow created: \(response.uri)")
7272+// Save this URI to unfollow later
7373+```
7474+7575+### Unfollow a User
7676+7777+Use ``RepoService/unfollow(uri:repo:)`` to unfollow:
7878+7979+```swift
8080+// The URI comes from viewer.following or from the follow response
8181+try await repoService.unfollow(
8282+ uri: "at://did:plc:mydid/app.bsky.graph.follow/abc123",
8383+ repo: myDID
8484+)
8585+```
8686+8787+## Blocks
8888+8989+### Get Blocked Users
9090+9191+Use ``BskyService/getBlocks(limit:cursor:)`` to list users you've blocked:
9292+9393+```swift
9494+let blocks = try await service.getBlocks(limit: 50)
9595+9696+for blocked in blocks.blocks {
9797+ print("Blocked: @\(blocked.handle)")
9898+}
9999+```
100100+101101+### Block a User
102102+103103+Use ``RepoService/block(did:repo:)`` to block someone:
104104+105105+```swift
106106+let response = try await repoService.block(
107107+ did: "did:plc:user-to-block",
108108+ repo: myDID
109109+)
110110+111111+print("Block created: \(response.uri)")
112112+```
113113+114114+### Unblock a User
115115+116116+Use ``RepoService/unblock(uri:repo:)`` to remove a block:
117117+118118+```swift
119119+try await repoService.unblock(
120120+ uri: "at://did:plc:mydid/app.bsky.graph.block/xyz",
121121+ repo: myDID
122122+)
123123+```
124124+125125+## Mutes
126126+127127+### Get Muted Users
128128+129129+Use ``BskyService/getMutes(limit:cursor:)`` to list muted users:
130130+131131+```swift
132132+let mutes = try await service.getMutes(limit: 50)
133133+134134+for muted in mutes.mutes {
135135+ print("Muted: @\(muted.handle)")
136136+}
137137+```
138138+139139+> Note: Muting is handled differently from blocks - mute/unmute operations use dedicated endpoints rather than record creation.
140140+141141+## Checking Relationships
142142+143143+The ``Viewer`` struct on profiles indicates the relationship:
144144+145145+```swift
146146+let profile = try await service.getProfile(for: handle)
147147+148148+if let viewer = profile.viewer {
149149+ // Check if you follow them
150150+ if let followUri = viewer.following {
151151+ print("You follow this user")
152152+ // Store followUri for unfollowing
153153+ }
154154+155155+ // Check if they follow you
156156+ if let _ = viewer.followedBy {
157157+ print("This user follows you")
158158+ }
159159+160160+ // Check mutual follow
161161+ if viewer.following != nil && viewer.followedBy != nil {
162162+ print("Mutual follow!")
163163+ }
164164+165165+ // Check mute status
166166+ if viewer.muted == true {
167167+ print("You have muted this user")
168168+ }
169169+170170+ // Check if they blocked you
171171+ if viewer.blockedBy == true {
172172+ print("This user has blocked you")
173173+ }
174174+175175+ // Check if you blocked them
176176+ if let _ = viewer.blocking {
177177+ print("You have blocked this user")
178178+ }
179179+}
180180+```
181181+182182+## Model Reference
183183+184184+### Follows Response
185185+186186+```swift
187187+public struct Follows: Codable, Sendable {
188188+ public let subject: FollowSubject
189189+ public let follows: [FollowProfile]
190190+ public let cursor: String?
191191+}
192192+193193+public struct FollowSubject: Codable, Sendable {
194194+ public let did: String
195195+ public let handle: String
196196+ public let displayName: String?
197197+ public let avatar: String?
198198+}
199199+200200+public struct FollowProfile: Codable, Sendable, Identifiable {
201201+ public let did: String
202202+ public let handle: String
203203+ public let displayName: String?
204204+ public let avatar: String?
205205+ public let description: String?
206206+ public let indexedAt: Date?
207207+ public let viewer: Viewer?
208208+209209+ public var id: String { did }
210210+}
211211+```
212212+213213+### Followers Response
214214+215215+```swift
216216+public struct Followers: Codable, Sendable {
217217+ public let subject: FollowSubject
218218+ public let followers: [FollowProfile]
219219+ public let cursor: String?
220220+}
221221+```
222222+223223+### Blocks Response
224224+225225+```swift
226226+public struct Blocks: Codable, Sendable {
227227+ public let blocks: [BlockedProfile]
228228+ public let cursor: String?
229229+}
230230+231231+public struct BlockedProfile: Codable, Sendable, Identifiable {
232232+ public let did: String
233233+ public let handle: String
234234+ public let displayName: String?
235235+ public let avatar: String?
236236+ public let viewer: Viewer?
237237+238238+ public var id: String { did }
239239+}
240240+```
241241+242242+### Mutes Response
243243+244244+```swift
245245+public struct Mutes: Codable, Sendable {
246246+ public let mutes: [MutedProfile]
247247+ public let cursor: String?
248248+}
249249+250250+public struct MutedProfile: Codable, Sendable, Identifiable {
251251+ public let did: String
252252+ public let handle: String
253253+ public let displayName: String?
254254+ public let avatar: String?
255255+ public let viewer: Viewer?
256256+257257+ public var id: String { did }
258258+}
259259+```
260260+261261+### Viewer
262262+263263+```swift
264264+public struct Viewer: Codable, Sendable {
265265+ public let muted: Bool?
266266+ public let mutedByList: String?
267267+ public let blockedBy: Bool?
268268+ public let blocking: String?
269269+ public let following: String?
270270+ public let followedBy: String?
271271+}
272272+```
273273+274274+## Pagination Pattern
275275+276276+All graph endpoints support cursor-based pagination:
277277+278278+```swift
279279+func fetchAllFollows(for did: String) async throws -> [FollowProfile] {
280280+ var allFollows: [FollowProfile] = []
281281+ var cursor: String? = nil
282282+283283+ repeat {
284284+ let response = try await service.getFollows(
285285+ for: did,
286286+ limit: 100,
287287+ cursor: cursor
288288+ )
289289+ allFollows.append(contentsOf: response.follows)
290290+ cursor = response.cursor
291291+ } while cursor != nil
292292+293293+ return allFollows
294294+}
295295+```
296296+297297+## See Also
298298+299299+- ``BskyService``
300300+- ``RepoService``
301301+- ``Follows``
302302+- ``Followers``
303303+- ``Blocks``
304304+- ``Mutes``
305305+- ``Viewer``
···11+# Timeline and Feeds
22+33+Read home timelines, author feeds, and work with posts.
44+55+## Overview
66+77+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.
88+99+## Reading the Home Timeline
1010+1111+Use ``BskyService/getTimeline(limit:cursor:)`` to fetch the authenticated user's home timeline:
1212+1313+```swift
1414+let service = await BskyService()
1515+1616+// Fetch the latest 50 posts
1717+let timeline = try await service.getTimeline(limit: 50)
1818+1919+for item in timeline.feed {
2020+ let post = item.post
2121+ let author = post.author
2222+2323+ print("@\(author.handle)")
2424+ if let displayName = author.displayName {
2525+ print(" \(displayName)")
2626+ }
2727+ print(" \(post.record.text)")
2828+ print(" Likes: \(post.likeCount) | Reposts: \(post.repostCount) | Replies: \(post.replyCount)")
2929+ print("")
3030+}
3131+```
3232+3333+## Pagination
3434+3535+Use cursors to paginate through large result sets:
3636+3737+```swift
3838+var cursor: String? = nil
3939+var allPosts: [TimelineItem] = []
4040+4141+repeat {
4242+ let timeline = try await service.getTimeline(limit: 100, cursor: cursor)
4343+ allPosts.append(contentsOf: timeline.feed)
4444+ cursor = timeline.cursor.isEmpty ? nil : timeline.cursor
4545+4646+ // Limit to 500 posts for this example
4747+ if allPosts.count >= 500 { break }
4848+} while cursor != nil
4949+5050+print("Fetched \(allPosts.count) posts")
5151+```
5252+5353+## Author Feeds
5454+5555+Fetch posts from a specific user:
5656+5757+```swift
5858+// Get posts by a specific user
5959+let authorFeed = try await service.getAuthorFeed(
6060+ for: "did:plc:abc123",
6161+ limit: 25
6262+)
6363+6464+for item in authorFeed.feed {
6565+ print(item.post.record.text)
6666+}
6767+6868+// Paginate author feed
6969+if let cursor = authorFeed.cursor {
7070+ let nextPage = try await service.getAuthorFeed(
7171+ for: "did:plc:abc123",
7272+ limit: 25,
7373+ cursor: cursor
7474+ )
7575+}
7676+```
7777+7878+## Post Threads
7979+8080+View a post and its replies:
8181+8282+```swift
8383+let postUri = "at://did:plc:abc123/app.bsky.feed.post/xyz789"
8484+8585+let thread = try await service.getPostThread(uri: postUri, depth: 6)
8686+8787+// thread.thread contains the post and nested replies
8888+print("Thread fetched with depth: 6")
8989+```
9090+9191+## Batch Fetching Posts
9292+9393+Fetch multiple posts by URI:
9494+9595+```swift
9696+let uris = [
9797+ "at://did:plc:abc/app.bsky.feed.post/123",
9898+ "at://did:plc:def/app.bsky.feed.post/456"
9999+]
100100+101101+let posts = try await service.getPosts(uris: uris)
102102+103103+for post in posts.posts {
104104+ print(post.record.text)
105105+}
106106+```
107107+108108+## Post Interactions
109109+110110+### Get Likes
111111+112112+See who liked a post:
113113+114114+```swift
115115+let likes = try await service.getLikes(
116116+ uri: "at://did:plc:abc/app.bsky.feed.post/123",
117117+ limit: 50
118118+)
119119+120120+print("Total likes: \(likes.likes.count)")
121121+for like in likes.likes {
122122+ print(" @\(like.actor.handle)")
123123+}
124124+```
125125+126126+### Get Reposts
127127+128128+See who reposted:
129129+130130+```swift
131131+let reposts = try await service.getRepostedBy(
132132+ uri: "at://did:plc:abc/app.bsky.feed.post/123",
133133+ limit: 50
134134+)
135135+136136+for repost in reposts.repostedBy {
137137+ print(" @\(repost.handle)")
138138+}
139139+```
140140+141141+## Feed Generators
142142+143143+Fetch custom feed generators:
144144+145145+```swift
146146+let feedUris = [
147147+ "at://did:plc:xxx/app.bsky.feed.generator/whats-hot",
148148+ "at://did:plc:yyy/app.bsky.feed.generator/tech"
149149+]
150150+151151+let generators = try await service.getFeedGenerators(for: feedUris)
152152+153153+for feed in generators.feeds {
154154+ print("\(feed.displayName ?? feed.uri)")
155155+ print(" Likes: \(feed.likeCount ?? 0)")
156156+ print(" Creator: @\(feed.creator.handle)")
157157+}
158158+```
159159+160160+## Understanding Timeline Structure
161161+162162+### Timeline
163163+164164+```swift
165165+public struct Timeline: Codable, Sendable {
166166+ public var feed: [TimelineItem]
167167+ public var cursor: String
168168+}
169169+```
170170+171171+### TimelineItem
172172+173173+Each item in the timeline wraps a post and optional reply context:
174174+175175+```swift
176176+public struct TimelineItem: Codable, Sendable, Identifiable {
177177+ public let post: Post
178178+ public let reply: Reply?
179179+180180+ public var id: String {
181181+ "\(post.uri ?? "")-\(post.cid ?? "")"
182182+ }
183183+}
184184+```
185185+186186+### Post
187187+188188+```swift
189189+public struct Post: Codable, Sendable {
190190+ public let uri: String?
191191+ public let cid: String?
192192+ public let author: Author
193193+ public let record: Record
194194+ public let replyCount: Int
195195+ public let repostCount: Int
196196+ public let likeCount: Int
197197+ public let indexedAt: String
198198+ public let viewer: Viewer
199199+ public let labels: [String]
200200+ public let embed: Embed?
201201+}
202202+```
203203+204204+### Record
205205+206206+The post content:
207207+208208+```swift
209209+public struct Record: Codable, Sendable {
210210+ public let text: String
211211+ public let type: String
212212+ public let langs: [String]?
213213+ public let reply: ReplyDetail?
214214+ public let createdAt: String
215215+ public let embed: Embed?
216216+ public let facets: [Facet]?
217217+}
218218+```
219219+220220+## Handling Embeds
221221+222222+Posts can contain various types of embedded content:
223223+224224+```swift
225225+if let embed = post.embed {
226226+ switch EmbedType(rawValue: embed.type) {
227227+ case .image:
228228+ // Image embed
229229+ if let images = embed.images {
230230+ for image in images {
231231+ print("Image: \(image.alt)")
232232+ }
233233+ }
234234+235235+ case .external:
236236+ // Link preview
237237+ if let external = embed.external {
238238+ print("Link: \(external.title)")
239239+ print("URL: \(external.uri ?? "")")
240240+ }
241241+242242+ case .record:
243243+ // Quote post
244244+ if let record = embed.record {
245245+ print("Quote: \(record.value?.text ?? "")")
246246+ }
247247+248248+ case .recordWithMedia:
249249+ // Quote post with images
250250+ print("Quote with media")
251251+252252+ default:
253253+ break
254254+ }
255255+}
256256+```
257257+258258+## See Also
259259+260260+- ``BskyService``
261261+- ``Timeline``
262262+- ``TimelineItem``
263263+- ``Post``
264264+- ``AuthorFeed``
265265+- <doc:RichTextGuide>
···11+# Working with Profiles
22+33+Fetch, search, and manage user profiles on Bluesky.
44+55+## Overview
66+77+User profiles are central to the Bluesky experience. bskyKit provides comprehensive APIs for fetching individual profiles, batch fetching multiple profiles, and searching for users.
88+99+## Fetching a Single Profile
1010+1111+Use ``BskyService/getProfile(for:)`` to fetch a profile by handle or DID:
1212+1313+```swift
1414+let service = await BskyService()
1515+1616+// By handle
1717+let profile = try await service.getProfile(for: "alice.bsky.social")
1818+1919+// By DID
2020+let profileByDID = try await service.getProfile(for: "did:plc:abc123...")
2121+```
2222+2323+The returned ``Profile`` contains:
2424+2525+| Property | Type | Description |
2626+|----------|------|-------------|
2727+| `did` | `String` | The decentralized identifier |
2828+| `handle` | `String` | The user's handle (e.g., alice.bsky.social) |
2929+| `displayName` | `String?` | Optional display name |
3030+| `description` | `String?` | User bio |
3131+| `avatar` | `String?` | URL to avatar image |
3232+| `banner` | `String?` | URL to banner image |
3333+| `followersCount` | `Int?` | Number of followers |
3434+| `followsCount` | `Int?` | Number of accounts followed |
3535+| `postsCount` | `Int?` | Number of posts |
3636+| `viewer` | `Viewer?` | Relationship with authenticated user |
3737+3838+## Batch Fetching Profiles
3939+4040+When you need multiple profiles, use ``BskyService/getProfiles(for:)`` for efficiency:
4141+4242+```swift
4343+let dids = [
4444+ "did:plc:abc123",
4545+ "did:plc:def456",
4646+ "did:plc:ghi789"
4747+]
4848+4949+let profiles = try await service.getProfiles(for: dids)
5050+5151+for profile in profiles.profiles {
5252+ print("@\(profile.handle): \(profile.displayName ?? "")")
5353+}
5454+```
5555+5656+> Tip: Batch fetching is more efficient than multiple individual requests when you need several profiles.
5757+5858+## Searching for Users
5959+6060+### Full Search
6161+6262+Use ``BskyService/searchActors(query:limit:)`` for comprehensive user search:
6363+6464+```swift
6565+let results = try await service.searchActors(query: "swift developer", limit: 25)
6666+6767+for actor in results.actors {
6868+ print("@\(actor.handle)")
6969+ print(" Name: \(actor.displayName ?? "N/A")")
7070+ print(" Bio: \(actor.description ?? "N/A")")
7171+}
7272+7373+// Handle pagination
7474+if let cursor = results.cursor {
7575+ // Fetch next page with cursor
7676+}
7777+```
7878+7979+### Typeahead Search
8080+8181+For autocomplete functionality, use ``BskyService/searchActorsTypeahead(query:limit:)``:
8282+8383+```swift
8484+// Fast search for autocomplete UI
8585+let typeahead = try await service.searchActorsTypeahead(query: "ali", limit: 5)
8686+8787+for actor in typeahead.actors {
8888+ print("@\(actor.handle)")
8989+}
9090+```
9191+9292+> Note: Typeahead search is optimized for speed and returns fewer fields than full search.
9393+9494+## Understanding Viewer State
9595+9696+The ``Viewer`` struct indicates the relationship between the authenticated user and a profile:
9797+9898+```swift
9999+if let viewer = profile.viewer {
100100+ if viewer.muted == true {
101101+ print("You have muted this user")
102102+ }
103103+104104+ if viewer.blockedBy == true {
105105+ print("This user has blocked you")
106106+ }
107107+108108+ if let followUri = viewer.following {
109109+ print("You follow this user")
110110+ // followUri is the AT-URI of your follow record
111111+ }
112112+113113+ if let followedByUri = viewer.followedBy {
114114+ print("This user follows you")
115115+ }
116116+}
117117+```
118118+119119+## User Preferences
120120+121121+Fetch the authenticated user's preferences:
122122+123123+```swift
124124+let preferences = try await service.getPreferences()
125125+126126+// Preferences contain saved feeds, pinned items, etc.
127127+for savedFeed in preferences.saved {
128128+ print("Saved: \(savedFeed)")
129129+}
130130+```
131131+132132+## Profile Model Reference
133133+134134+### Profile
135135+136136+```swift
137137+public struct Profile: Codable, Sendable, Identifiable {
138138+ public let did: String
139139+ public let handle: String
140140+ public let displayName: String?
141141+ public let description: String?
142142+ public let avatar: String?
143143+ public let banner: String?
144144+ public let followsCount: Int?
145145+ public let followersCount: Int?
146146+ public let postsCount: Int?
147147+ public let indexedAt: Date?
148148+ public let viewer: Viewer?
149149+ public let labels: [AuthorLabels]?
150150+151151+ public var id: String { did }
152152+}
153153+```
154154+155155+### ActorProfile
156156+157157+Used in search results:
158158+159159+```swift
160160+public struct ActorProfile: Codable, Sendable, Identifiable {
161161+ public let did: String
162162+ public let handle: String
163163+ public let displayName: String?
164164+ public let avatar: String?
165165+ public let description: String?
166166+ public let indexedAt: Date?
167167+ public let viewer: Viewer?
168168+ public let labels: [AuthorLabels]?
169169+170170+ public var id: String { did }
171171+}
172172+```
173173+174174+## See Also
175175+176176+- ``BskyService``
177177+- ``Profile``
178178+- ``ActorProfile``
179179+- ``Viewer``
180180+- <doc:SocialGraph>
+116
Sources/bskyKit/Documentation.docc/bskyKit.md
···11+# ``bskyKit``
22+33+A Swift SDK for interacting with Bluesky social network APIs.
44+55+## Overview
66+77+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.
88+99+### Key Features
1010+1111+- **Profile Management**: Fetch user profiles, search for users, and get preferences
1212+- **Timeline & Feeds**: Read home timelines, author feeds, and custom feed generators
1313+- **Social Graph**: Manage follows, followers, blocks, and mutes
1414+- **Rich Text**: Auto-detect mentions, links, and hashtags with proper byte indexing
1515+- **Write Operations**: Create posts, likes, reposts, follows, and blocks
1616+- **Notifications**: List notifications and manage read state
1717+1818+### Quick Start
1919+2020+```swift
2121+import bskyKit
2222+import CoreATProtocol
2323+2424+// Configure the environment
2525+await setup(
2626+ hostURL: "https://bsky.social",
2727+ accessJWT: "your-access-token",
2828+ refreshJWT: nil
2929+)
3030+3131+// Fetch a profile
3232+let service = await BskyService()
3333+let profile = try await service.getProfile(for: "alice.bsky.social")
3434+print("@\(profile.handle): \(profile.displayName ?? "")")
3535+3636+// Get timeline
3737+let timeline = try await service.getTimeline(limit: 20)
3838+for item in timeline.feed {
3939+ print(item.post.record.text)
4040+}
4141+```
4242+4343+### Architecture
4444+4545+bskyKit is organized into several key components:
4646+4747+- **BskyService**: The main entry point for reading Bluesky data
4848+- **RepoService**: Handles write operations (create/delete records)
4949+- **RichText**: Utilities for handling rich text with facets
5050+- **Models**: Type-safe representations of Bluesky data structures
5151+5252+## Topics
5353+5454+### Essentials
5555+5656+- <doc:GettingStarted>
5757+- ``BskyService``
5858+- ``RepoService``
5959+6060+### Working with Profiles
6161+6262+- <doc:WorkingWithProfiles>
6363+- ``Profile``
6464+- ``ActorProfile``
6565+- ``Viewer``
6666+6767+### Timeline and Feeds
6868+6969+- <doc:TimelineAndFeeds>
7070+- ``Timeline``
7171+- ``TimelineItem``
7272+- ``Post``
7373+- ``AuthorFeed``
7474+7575+### Rich Text
7676+7777+- <doc:RichTextGuide>
7878+- ``RichText``
7979+- ``RichTextFacet``
8080+- ``RichTextFeature``
8181+8282+### Social Actions
8383+8484+- <doc:SocialActions>
8585+- ``PostRecord``
8686+- ``ReplyRef``
8787+- ``PostEmbed``
8888+8989+### Social Graph
9090+9191+- <doc:SocialGraph>
9292+- ``Follows``
9393+- ``Followers``
9494+- ``FollowProfile``
9595+9696+### Notifications
9797+9898+- <doc:Notifications>
9999+- ``NotificationsResponse``
100100+- ``Notification``
101101+- ``NotificationReason``
102102+103103+### Response Types
104104+105105+- ``CreateRecordResponse``
106106+- ``GetRecordResponse``
107107+- ``ListRecordsResponse``
108108+109109+### Supporting Types
110110+111111+- ``Feed``
112112+- ``Feeds``
113113+- ``Creator``
114114+- ``Preferences``
115115+- ``Blocks``
116116+- ``Mutes``
+14
Sources/bskyKit/Models/AuthorFeed.swift
···11+//
22+// AuthorFeed.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+1010+/// Response from app.bsky.feed.getAuthorFeed
1111+public struct AuthorFeed: Codable, Sendable {
1212+ public let feed: [TimelineItem]
1313+ public let cursor: String?
1414+}
+18
Sources/bskyKit/Models/Creator.swift
···11+//
22+// Creator.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 10/11/25.
66+//
77+88+public struct Creator: Codable, Sendable, Identifiable {
99+ public let did: String
1010+ public let handle: String
1111+ public let displayName: String?
1212+ public let avatar: String?
1313+ public let viewer: Viewer?
1414+ public let labels: [AuthorLabels]?
1515+1616+ /// Stable identifier based on DID
1717+ public var id: String { did }
1818+}
+28
Sources/bskyKit/Models/Feed.swift
···11+//
22+// Feed.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 10/11/25.
66+//
77+88+import Foundation
99+1010+public struct Feed: Codable, Sendable, Identifiable {
1111+ public let uri: String
1212+ public let cid: String
1313+ public let did: String
1414+ public let creator: Creator
1515+ public let displayName: String
1616+ public let description: String?
1717+ public let avatar: String?
1818+ public let likeCount: Int?
1919+ public let viewer: Viewer?
2020+ public let indexedAt: Date?
2121+2222+ /// Stable identifier based on URI
2323+ public var id: String { uri }
2424+}
2525+2626+public struct Feeds: Codable, Sendable {
2727+ public let feeds: [Feed]
2828+}
+59
Sources/bskyKit/Models/Follows.swift
···11+//
22+// Follows.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+1010+/// Response from app.bsky.graph.getFollows
1111+public struct Follows: Codable, Sendable {
1212+ /// The subject (user) whose follows are being listed
1313+ public let subject: FollowSubject
1414+1515+ /// List of accounts the subject follows
1616+ public let follows: [FollowProfile]
1717+1818+ /// Pagination cursor for fetching more results
1919+ public let cursor: String?
2020+}
2121+2222+/// The subject of a follows query
2323+public struct FollowSubject: Codable, Sendable {
2424+ public let did: String
2525+ public let handle: String
2626+ public let displayName: String?
2727+ public let avatar: String?
2828+ public let description: String?
2929+ public let indexedAt: Date?
3030+ public let viewer: Viewer?
3131+ public let labels: [AuthorLabels]?
3232+}
3333+3434+/// A profile in the follows list
3535+public struct FollowProfile: Codable, Sendable, Identifiable {
3636+ public let did: String
3737+ public let handle: String
3838+ public let displayName: String?
3939+ public let avatar: String?
4040+ public let description: String?
4141+ public let indexedAt: Date?
4242+ public let viewer: Viewer?
4343+ public let labels: [AuthorLabels]?
4444+4545+ /// Stable identifier based on DID
4646+ public var id: String { did }
4747+}
4848+4949+/// Response from app.bsky.graph.getFollowers
5050+public struct Followers: Codable, Sendable {
5151+ /// The subject (user) whose followers are being listed
5252+ public let subject: FollowSubject
5353+5454+ /// List of accounts following the subject
5555+ public let followers: [FollowProfile]
5656+5757+ /// Pagination cursor for fetching more results
5858+ public let cursor: String?
5959+}
+48
Sources/bskyKit/Models/Graph.swift
···11+//
22+// Graph.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+1010+/// Response from app.bsky.graph.getBlocks
1111+public struct Blocks: Codable, Sendable {
1212+ public let blocks: [BlockedProfile]
1313+ public let cursor: String?
1414+}
1515+1616+/// A blocked profile
1717+public struct BlockedProfile: Codable, Sendable, Identifiable {
1818+ public let did: String
1919+ public let handle: String
2020+ public let displayName: String?
2121+ public let avatar: String?
2222+ public let description: String?
2323+ public let indexedAt: Date?
2424+ public let viewer: Viewer?
2525+ public let labels: [AuthorLabels]?
2626+2727+ public var id: String { did }
2828+}
2929+3030+/// Response from app.bsky.graph.getMutes
3131+public struct Mutes: Codable, Sendable {
3232+ public let mutes: [MutedProfile]
3333+ public let cursor: String?
3434+}
3535+3636+/// A muted profile
3737+public struct MutedProfile: Codable, Sendable, Identifiable {
3838+ public let did: String
3939+ public let handle: String
4040+ public let displayName: String?
4141+ public let avatar: String?
4242+ public let description: String?
4343+ public let indexedAt: Date?
4444+ public let viewer: Viewer?
4545+ public let labels: [AuthorLabels]?
4646+4747+ public var id: String { did }
4848+}
+59
Sources/bskyKit/Models/Interactions.swift
···11+//
22+// Interactions.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+1010+/// Response from app.bsky.feed.getLikes
1111+public struct Likes: Codable, Sendable {
1212+ public let uri: String
1313+ public let cid: String?
1414+ public let likes: [Like]
1515+ public let cursor: String?
1616+}
1717+1818+/// A single like
1919+public struct Like: Codable, Sendable, Identifiable {
2020+ public let actor: LikeActor
2121+ public let createdAt: Date
2222+ public let indexedAt: Date
2323+2424+ public var id: String { "\(actor.did)-\(indexedAt.timeIntervalSince1970)" }
2525+}
2626+2727+/// Actor who liked a post
2828+public struct LikeActor: Codable, Sendable, Identifiable {
2929+ public let did: String
3030+ public let handle: String
3131+ public let displayName: String?
3232+ public let avatar: String?
3333+ public let viewer: Viewer?
3434+ public let labels: [AuthorLabels]?
3535+3636+ public var id: String { did }
3737+}
3838+3939+/// Response from app.bsky.feed.getRepostedBy
4040+public struct RepostedBy: Codable, Sendable {
4141+ public let uri: String
4242+ public let cid: String?
4343+ public let repostedBy: [RepostActor]
4444+ public let cursor: String?
4545+}
4646+4747+/// Actor who reposted a post
4848+public struct RepostActor: Codable, Sendable, Identifiable {
4949+ public let did: String
5050+ public let handle: String
5151+ public let displayName: String?
5252+ public let avatar: String?
5353+ public let description: String?
5454+ public let indexedAt: Date?
5555+ public let viewer: Viewer?
5656+ public let labels: [AuthorLabels]?
5757+5858+ public var id: String { did }
5959+}
+73
Sources/bskyKit/Models/Notifications.swift
···11+//
22+// Notifications.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+1010+/// Response from app.bsky.notification.listNotifications
1111+public struct NotificationsResponse: Codable, Sendable {
1212+ public let notifications: [Notification]
1313+ public let cursor: String?
1414+ public let seenAt: Date?
1515+}
1616+1717+/// A single notification
1818+public struct Notification: Codable, Sendable, Identifiable {
1919+ public let uri: String
2020+ public let cid: String
2121+ public let author: NotificationAuthor
2222+ public let reason: NotificationReason
2323+ public let reasonSubject: String?
2424+ public let record: NotificationRecord?
2525+ public let isRead: Bool
2626+ public let indexedAt: Date
2727+2828+ public var id: String { uri }
2929+}
3030+3131+/// Author of a notification
3232+public struct NotificationAuthor: Codable, Sendable, Identifiable {
3333+ public let did: String
3434+ public let handle: String
3535+ public let displayName: String?
3636+ public let avatar: String?
3737+ public let viewer: Viewer?
3838+ public let labels: [AuthorLabels]?
3939+4040+ public var id: String { did }
4141+}
4242+4343+/// Reason for a notification
4444+public enum NotificationReason: String, Codable, Sendable {
4545+ case like
4646+ case repost
4747+ case follow
4848+ case mention
4949+ case reply
5050+ case quote
5151+ case starterpackJoined = "starterpack-joined"
5252+}
5353+5454+/// Record attached to notification (simplified)
5555+public struct NotificationRecord: Codable, Sendable {
5656+ public let type: String?
5757+ public let text: String?
5858+ public let createdAt: Date?
5959+6060+ enum CodingKeys: String, CodingKey {
6161+ case type = "$type"
6262+ case text
6363+ case createdAt
6464+ }
6565+}
6666+6767+/// Response from app.bsky.notification.getUnreadCount
6868+public struct UnreadCount: Codable, Sendable {
6969+ public let count: Int
7070+}
7171+7272+/// Empty response for procedures like updateSeen
7373+public struct EmptyResponse: Codable, Sendable {}
+137
Sources/bskyKit/Models/PostThread.swift
···11+//
22+// PostThread.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+1010+/// Response from app.bsky.feed.getPostThread
1111+public struct PostThreadResponse: Codable, Sendable {
1212+ public let thread: ThreadViewPost
1313+}
1414+1515+/// A post in a thread with parent/replies context
1616+/// Uses class for recursive structure support
1717+public final class ThreadViewPost: Codable, Sendable, Identifiable {
1818+ public let type: String?
1919+ public let post: Post
2020+ public let parent: ThreadParent?
2121+ public let replies: [ThreadReply]?
2222+2323+ public var id: String { post.uri ?? "" }
2424+2525+ enum CodingKeys: String, CodingKey {
2626+ case type = "$type"
2727+ case post, parent, replies
2828+ }
2929+3030+ public init(type: String?, post: Post, parent: ThreadParent?, replies: [ThreadReply]?) {
3131+ self.type = type
3232+ self.post = post
3333+ self.parent = parent
3434+ self.replies = replies
3535+ }
3636+}
3737+3838+/// Parent of a thread post (can be another post or blocked/not found)
3939+public indirect enum ThreadParent: Codable, Sendable {
4040+ case post(ThreadViewPost)
4141+ case notFound(NotFoundPost)
4242+ case blocked(BlockedPost)
4343+4444+ public init(from decoder: Decoder) throws {
4545+ let container = try decoder.container(keyedBy: CodingKeys.self)
4646+ let type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
4747+4848+ if type.contains("notFoundPost") {
4949+ self = .notFound(try NotFoundPost(from: decoder))
5050+ } else if type.contains("blockedPost") {
5151+ self = .blocked(try BlockedPost(from: decoder))
5252+ } else {
5353+ self = .post(try ThreadViewPost(from: decoder))
5454+ }
5555+ }
5656+5757+ public func encode(to encoder: Encoder) throws {
5858+ switch self {
5959+ case .post(let threadPost):
6060+ try threadPost.encode(to: encoder)
6161+ case .notFound(let notFound):
6262+ try notFound.encode(to: encoder)
6363+ case .blocked(let blocked):
6464+ try blocked.encode(to: encoder)
6565+ }
6666+ }
6767+6868+ enum CodingKeys: String, CodingKey {
6969+ case type = "$type"
7070+ }
7171+}
7272+7373+/// Reply to a thread post (can be another post or blocked/not found)
7474+public indirect enum ThreadReply: Codable, Sendable {
7575+ case post(ThreadViewPost)
7676+ case notFound(NotFoundPost)
7777+ case blocked(BlockedPost)
7878+7979+ public init(from decoder: Decoder) throws {
8080+ let container = try decoder.container(keyedBy: CodingKeys.self)
8181+ let type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
8282+8383+ if type.contains("notFoundPost") {
8484+ self = .notFound(try NotFoundPost(from: decoder))
8585+ } else if type.contains("blockedPost") {
8686+ self = .blocked(try BlockedPost(from: decoder))
8787+ } else {
8888+ self = .post(try ThreadViewPost(from: decoder))
8989+ }
9090+ }
9191+9292+ public func encode(to encoder: Encoder) throws {
9393+ switch self {
9494+ case .post(let threadPost):
9595+ try threadPost.encode(to: encoder)
9696+ case .notFound(let notFound):
9797+ try notFound.encode(to: encoder)
9898+ case .blocked(let blocked):
9999+ try blocked.encode(to: encoder)
100100+ }
101101+ }
102102+103103+ enum CodingKeys: String, CodingKey {
104104+ case type = "$type"
105105+ }
106106+}
107107+108108+/// A post that was not found
109109+public struct NotFoundPost: Codable, Sendable {
110110+ public let type: String
111111+ public let uri: String
112112+ public let notFound: Bool
113113+114114+ enum CodingKeys: String, CodingKey {
115115+ case type = "$type"
116116+ case uri, notFound
117117+ }
118118+}
119119+120120+/// A post that was blocked
121121+public struct BlockedPost: Codable, Sendable {
122122+ public let type: String
123123+ public let uri: String
124124+ public let blocked: Bool
125125+ public let author: BlockedAuthor
126126+127127+ enum CodingKeys: String, CodingKey {
128128+ case type = "$type"
129129+ case uri, blocked, author
130130+ }
131131+}
132132+133133+/// Author info for a blocked post
134134+public struct BlockedAuthor: Codable, Sendable {
135135+ public let did: String
136136+ public let viewer: Viewer?
137137+}
+13
Sources/bskyKit/Models/Posts.swift
···11+//
22+// Posts.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+1010+/// Response from app.bsky.feed.getPosts
1111+public struct Posts: Codable, Sendable {
1212+ public let posts: [Post]
1313+}
+25
Sources/bskyKit/Models/Preferences.swift
···11+//
22+// Preferences.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 10/11/25.
66+//
77+88+import Foundation
99+1010+public struct Preferences: Codable, Sendable {
1111+ public var preferences: [Preference]
1212+}
1313+1414+public struct Preference: Codable, Sendable {
1515+ public let type: String
1616+ public var saved: [String]
1717+ public var pinned: [String]
1818+1919+ enum CodingKeys: String, CodingKey {
2020+ case type = "$type"
2121+ case saved
2222+ case pinned
2323+ }
2424+}
2525+
+26
Sources/bskyKit/Models/Profile.swift
···11+//
22+// Profile.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 10/11/25.
66+//
77+88+import Foundation
99+1010+public struct Profile: Codable, Sendable, Identifiable {
1111+ public let did: String
1212+ public let handle: String
1313+ public let displayName: String?
1414+ public let description: String?
1515+ public let avatar: String?
1616+ public let banner: String?
1717+ public let followsCount: Int?
1818+ public let followersCount: Int?
1919+ public let postsCount: Int?
2020+ public let indexedAt: Date?
2121+ public let viewer: Viewer?
2222+ public let labels: [AuthorLabels]?
2323+2424+ /// Stable identifier based on DID
2525+ public var id: String { did }
2626+}
+38
Sources/bskyKit/Models/SearchActors.swift
···11+//
22+// SearchActors.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+1010+/// Response from app.bsky.actor.searchActors
1111+public struct SearchActorsResult: Codable, Sendable {
1212+ public let actors: [ActorProfile]
1313+ public let cursor: String?
1414+}
1515+1616+/// Response from app.bsky.actor.searchActorsTypeahead
1717+public struct SearchActorsTypeaheadResult: Codable, Sendable {
1818+ public let actors: [ActorProfile]
1919+}
2020+2121+/// A profile returned from actor search
2222+public struct ActorProfile: Codable, Sendable, Identifiable {
2323+ public let did: String
2424+ public let handle: String
2525+ public let displayName: String?
2626+ public let avatar: String?
2727+ public let description: String?
2828+ public let indexedAt: Date?
2929+ public let viewer: Viewer?
3030+ public let labels: [AuthorLabels]?
3131+3232+ public var id: String { did }
3333+}
3434+3535+/// Response from app.bsky.actor.getProfiles
3636+public struct Profiles: Codable, Sendable {
3737+ public let profiles: [ActorProfile]
3838+}
+370
Sources/bskyKit/Models/Timeline.swift
···11+//
22+// Timeline.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 10/11/25.
66+//
77+88+import Foundation
99+1010+public struct Timeline: Codable, Sendable {
1111+ public var feed: [TimelineItem]
1212+ public var cursor: String
1313+}
1414+1515+public struct TimelineItem: Codable, Sendable {
1616+ public let post: Post
1717+ public let reply: Reply?
1818+}
1919+/*
2020+{
2121+ "post": {
2222+ "uri": "at://did:plc:gkqxrdozmfap5ehgd5xlhem2/app.bsky.feed.post/3l7r3os55mj2r",
2323+ "cid": "bafyreign5fpvfsfj6xriqbdkp3pwf5lmcfzvkedxy6yi3csxmwfkf7cdiu",
2424+ "author": {
2525+ "did": "did:plc:gkqxrdozmfap5ehgd5xlhem2",
2626+ "handle": "atprotesting123.bsky.social",
2727+ "viewer": {
2828+ "muted": false,
2929+ "blockedBy": false,
3030+ "following": "at://did:plc:aq5iwu4gjdcg2hq53llism3x/app.bsky.graph.follow/3l7oxdij6km2a",
3131+ "followedBy": "at://did:plc:gkqxrdozmfap5ehgd5xlhem2/app.bsky.graph.follow/3kcyrue74z32v"
3232+ },
3333+ "labels": [],
3434+ "createdAt": "2023-10-30T21:44:11.344Z"
3535+ },
3636+ "record": {
3737+ "$type": "app.bsky.feed.post",
3838+ "createdAt": "2024-10-30T21:30:34.509Z",
3939+ "facets": [
4040+ {
4141+ "features": [
4242+ {
4343+ "$type": "app.bsky.richtext.facet#link",
4444+ "uri": "https://x.com"
4545+ }
4646+ ],
4747+ "index": {
4848+ "byteEnd": 5,
4949+ "byteStart": 0
5050+ }
5151+ }
5252+ ],
5353+ "langs": [
5454+ "en"
5555+ ],
5656+ "text": "x.com"
5757+ },
5858+ "replyCount": 0,
5959+ "repostCount": 0,
6060+ "likeCount": 0,
6161+ "quoteCount": 0,
6262+ "indexedAt": "2024-10-30T21:30:34.509Z",
6363+ "viewer": {
6464+ "threadMuted": false,
6565+ "embeddingDisabled": false
6666+ },
6767+ "labels": []
6868+ }
6969+ },*/
7070+7171+extension TimelineItem: Equatable {
7272+ public static func == (lhs: TimelineItem, rhs: TimelineItem) -> Bool {
7373+ lhs.post.uri == rhs.post.uri && lhs.post.cid == rhs.post.cid
7474+ }
7575+}
7676+7777+extension TimelineItem: Identifiable {
7878+ /// Stable identifier based on post URI and CID
7979+ public var id: String {
8080+ "\(post.uri ?? "")-\(post.cid ?? "")"
8181+ }
8282+}
8383+8484+public struct Post: Codable, Sendable {
8585+ public let uri: String?
8686+ public let cid: String?
8787+ public let author: Author
8888+ public let record: Record
8989+ public let facets: PostFacet?
9090+ public let replyCount: Int
9191+ public let repostCount: Int
9292+ public let likeCount: Int
9393+ public let indexedAt: String
9494+ public let viewer: Viewer
9595+ public let labels: [String]
9696+ public let embed: Embed?
9797+}
9898+9999+public struct PostFacet: Codable, Sendable {
100100+ public let facets: [Facet]
101101+ public let createdAt: Date
102102+}
103103+104104+public struct Facet: Codable, Sendable {
105105+ public let index: FacetIndex
106106+ public let features: [FacetFeature]
107107+108108+}
109109+110110+public struct FacetFeature: Codable, Sendable {
111111+ public let uri: String?
112112+ public let type: FacetType
113113+114114+ enum CodingKeys: String, CodingKey {
115115+ case uri
116116+ case type = "$type"
117117+ }
118118+}
119119+120120+public enum FacetType: Codable, Sendable {
121121+ case link(String)
122122+ case unknown(String)
123123+124124+ public init(from decoder: Decoder) throws {
125125+ let container = try decoder.singleValueContainer()
126126+ let value = try container.decode(String.self)
127127+128128+ switch value {
129129+ case "app.bsky.richtext.facet#link": self = .link(value)
130130+ default: self = .unknown(value)
131131+ }
132132+ }
133133+134134+ public func encode(to encoder: Encoder) throws {
135135+ var container = encoder.singleValueContainer()
136136+ switch self {
137137+ case .link(let value), .unknown(let value):
138138+ try container.encode(value)
139139+ }
140140+ }
141141+}
142142+143143+public struct FacetIndex: Codable, Sendable {
144144+ public let byteEnd: Int
145145+ public let byteStart: Int
146146+}
147147+148148+public struct Embed: Codable, Sendable {
149149+ public let type: String
150150+ public let images: [EmbeddedMedia]?
151151+ public let media: Media?
152152+ public let record: EmbedRecord?
153153+ public let external: EmbedExternal?
154154+155155+ enum CodingKeys: String, CodingKey {
156156+ case images, media, record, external
157157+ case type = "$type"
158158+ }
159159+}
160160+161161+public struct EmbedExternal: Codable, Sendable {
162162+ public let uri: String?
163163+ public let thumb: TimelineImage?
164164+ public let title: String
165165+ public let externalDescription: String
166166+167167+ enum CodingKeys: String, CodingKey {
168168+ case uri, thumb, title
169169+ case externalDescription = "description"
170170+ }
171171+}
172172+173173+public struct EmbedRecord: Codable, Sendable {
174174+ public let type: String?
175175+ public let record: UnpopulatedPost?
176176+ public let uri: String?
177177+ public let cid: String?
178178+ public let author: Author?
179179+ public let value: EmbedRecordValue?
180180+// public let labels: [String]
181181+// public let indexedAt: Date
182182+// public let embeds: [String] // TODO: This isn't correct
183183+184184+185185+ enum CodingKeys: String, CodingKey {
186186+ case type = "$type"
187187+ case record, uri, cid, author, value/*, labels, indexedAt, embeds*/
188188+ }
189189+}
190190+191191+public struct EmbedRecordValue: Codable, Sendable {
192192+ public let text: String
193193+ public let type: String
194194+ public let langs: [String]?
195195+ public let reply: ReplyDetail?
196196+ public let createdAt: String
197197+198198+ enum CodingKeys: String, CodingKey {
199199+ case type = "$type"
200200+ case langs, reply, createdAt, text
201201+ }
202202+}
203203+204204+public struct Media: Codable, Sendable {
205205+ public let type: String
206206+ public let images: [EmbeddedMedia]?
207207+208208+ enum CodingKeys: String, CodingKey {
209209+ case type = "$type"
210210+ case images
211211+ }
212212+}
213213+214214+public enum EmbedType: String, Codable, Sendable {
215215+ case image = "app.bsky.embed.images"
216216+ case recordWithMedia = "app.bsky.embed.recordWithMedia"
217217+ case external = "app.bsky.embed.external"
218218+ case record = "app.bsky.embed.record"
219219+}
220220+221221+public enum TimelineImage: Codable, Sendable, Identifiable {
222222+ case string(String)
223223+ case image(EmbeddedImage)
224224+225225+ public init(from decoder: Decoder) throws {
226226+ let container = try decoder.singleValueContainer()
227227+228228+ if let string = try? container.decode(String.self) {
229229+ self = .string(string)
230230+ return
231231+ }
232232+233233+ if let image = try? container.decode(EmbeddedImage.self) {
234234+ self = .image(image)
235235+ return
236236+ }
237237+238238+ throw DecodingError.typeMismatch(TimelineImage.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for MyProperty"))
239239+ }
240240+241241+ public func encode(to encoder: Encoder) throws {
242242+ var container = encoder.singleValueContainer()
243243+ switch self {
244244+ case .string(let string):
245245+ try container.encode(string)
246246+ case .image(let image):
247247+ try container.encode(image)
248248+ }
249249+ }
250250+251251+ /// Stable identifier based on content
252252+ public var id: String {
253253+ switch self {
254254+ case .string(let value):
255255+ return value
256256+ case .image(let img):
257257+ return "\(img.type)-\(img.size)"
258258+ }
259259+ }
260260+}
261261+262262+public struct EmbeddedMedia: Codable, Sendable {
263263+ public let thumb: TimelineImage?
264264+ public let fullsize: String?
265265+ public let alt: String
266266+ public let aspectRatio: EmbedImageAspectRatio?
267267+ public let image: TimelineImage?
268268+}
269269+270270+public struct EmbeddedImage: Codable, Sendable {
271271+ public let type: String
272272+ public let ref: [String : String]
273273+ public let mimeType: String
274274+ public let size: Int
275275+276276+ enum CodingKeys: String, CodingKey {
277277+ case type = "$type"
278278+ case ref, mimeType, size
279279+ }
280280+}
281281+282282+public struct EmbedImageAspectRatio: Codable, Sendable {
283283+ public let width: Int
284284+ public let height: Int
285285+}
286286+287287+public struct Reply: Codable, Sendable {
288288+ public let root: Root
289289+ public let parent: Parent
290290+}
291291+292292+public struct Author: Codable, Sendable {
293293+ public let did: String
294294+ public let handle: String
295295+ public let displayName: String?
296296+ public let avatar: String?
297297+ public let viewer: Viewer
298298+ public let labels: [AuthorLabels]
299299+}
300300+301301+public struct AuthorLabels: Codable, Sendable {
302302+ public let src: String
303303+ public let uri: String?
304304+ public let cid: String?
305305+ public let val: String
306306+ public let cts: String
307307+}
308308+309309+public struct Record: Codable, Sendable {
310310+ public let text: String
311311+ public let type: String
312312+ public let langs: [String]?
313313+ public let reply: ReplyDetail?
314314+ public let createdAt: String
315315+ public let embed: Embed?
316316+ public let facets: [Facet]?
317317+318318+ enum CodingKeys: String, CodingKey {
319319+ case type = "$type"
320320+ case langs, reply, createdAt, embed, text, facets
321321+ }
322322+}
323323+324324+public struct ReplyDetail: Codable, Sendable {
325325+ public let root: UnpopulatedPost
326326+ public let parent: UnpopulatedPost
327327+}
328328+329329+public struct UnpopulatedPost: Codable, Sendable {
330330+ public let cid: String?
331331+ public let uri: String?
332332+}
333333+334334+public struct Root: Codable, Sendable {
335335+ public let type: String
336336+ public let uri: String?
337337+ public let cid: String?
338338+ public let author: Author
339339+ public let record: Record
340340+ public let replyCount: Int
341341+ public let repostCount: Int
342342+ public let likeCount: Int
343343+ public let indexedAt: String
344344+ public let viewer: Viewer
345345+ public let labels: [String]
346346+347347+ enum CodingKeys: String, CodingKey {
348348+ case type = "$type"
349349+ case uri, cid, author, record, replyCount, repostCount, likeCount, indexedAt, viewer, labels
350350+ }
351351+}
352352+353353+public struct Parent: Codable, Sendable {
354354+ public let type: String
355355+ public let uri: String?
356356+ public let cid: String?
357357+ public let author: Author
358358+ public let record: Record
359359+ public let replyCount: Int
360360+ public let repostCount: Int
361361+ public let likeCount: Int
362362+ public let indexedAt: String
363363+ public let viewer: Viewer
364364+ public let labels: [String]
365365+366366+ enum CodingKeys: String, CodingKey {
367367+ case type = "$type"
368368+ case uri, cid, author, record, replyCount, repostCount, likeCount, indexedAt, viewer, labels
369369+ }
370370+}
+34
Sources/bskyKit/Models/Viewer.swift
···11+//
22+// Viewer.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 10/11/25.
66+//
77+88+public struct Viewer: Codable, Sendable {
99+ public let muted: Bool?
1010+ public let blockedBy: Bool?
1111+ public let following: String?
1212+ public let followedBy: String?
1313+ public let blocking: String?
1414+ public let mutedByList: String?
1515+ public let blockingByList: String?
1616+1717+ public init(
1818+ muted: Bool? = nil,
1919+ blockedBy: Bool? = nil,
2020+ following: String? = nil,
2121+ followedBy: String? = nil,
2222+ blocking: String? = nil,
2323+ mutedByList: String? = nil,
2424+ blockingByList: String? = nil
2525+ ) {
2626+ self.muted = muted
2727+ self.blockedBy = blockedBy
2828+ self.following = following
2929+ self.followedBy = followedBy
3030+ self.blocking = blocking
3131+ self.mutedByList = mutedByList
3232+ self.blockingByList = blockingByList
3333+ }
3434+}
+31
Sources/bskyKit/OAuth/BskyOAuth.swift
···11+import Foundation
22+@_exported import CoreATProtocol
33+44+// MARK: - Re-export OAuth types from CoreATProtocol
55+66+/// Re-export ATProtoOAuth as BskyOAuth for Bluesky-specific usage
77+public typealias BskyOAuth = ATProtoOAuth
88+99+/// Re-export OAuth configuration
1010+public typealias BskyOAuthConfig = ATProtoOAuthConfig
1111+1212+/// Re-export OAuth storage
1313+public typealias BskyAuthStorage = ATProtoAuthStorage
1414+1515+/// Re-export OAuth result
1616+public typealias BskyAuthResult = ATProtoAuthResult
1717+1818+/// Re-export OAuth errors
1919+public typealias BskyOAuthError = ATProtoOAuthError
2020+2121+/// Re-export identity errors
2222+public typealias BskyIdentityError = IdentityError
2323+2424+/// Re-export user authenticator type
2525+public typealias BskyUserAuthenticator = UserAuthenticator
2626+2727+// MARK: - Re-export OAuthenticator types (already re-exported from CoreATProtocol)
2828+// Login, Token, and LoginStorage are already available via CoreATProtocol
2929+3030+// MARK: - Re-export ErrorMessage
3131+public typealias BskyErrorMessage = ErrorMessage
+69
Sources/bskyKit/RepoAPI.swift
···11+//
22+// RepoAPI.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+import CoreATProtocol
1010+1111+/// API endpoints for com.atproto.repo.* lexicons
1212+enum RepoAPI: Sendable {
1313+ case createRecord(body: Data)
1414+ case deleteRecord(body: Data)
1515+ case getRecord(repo: String, collection: String, rkey: String)
1616+ case listRecords(repo: String, collection: String, limit: Int, cursor: String?)
1717+ // Note: uploadBlob requires CoreATProtocol updates - deferred
1818+}
1919+2020+extension RepoAPI: EndpointType {
2121+ public var baseURL: URL {
2222+ get async {
2323+ guard let host = await APEnvironment.current.host else { fatalError("Host not set.") }
2424+ guard let url = URL(string: host) else { fatalError("RepoAPI baseURL not configured.") }
2525+ return url
2626+ }
2727+ }
2828+2929+ var path: String {
3030+ switch self {
3131+ case .createRecord: "/xrpc/com.atproto.repo.createRecord"
3232+ case .deleteRecord: "/xrpc/com.atproto.repo.deleteRecord"
3333+ case .getRecord: "/xrpc/com.atproto.repo.getRecord"
3434+ case .listRecords: "/xrpc/com.atproto.repo.listRecords"
3535+ }
3636+ }
3737+3838+ var httpMethod: HTTPMethod {
3939+ switch self {
4040+ case .createRecord, .deleteRecord:
4141+ return .post
4242+ case .getRecord, .listRecords:
4343+ return .get
4444+ }
4545+ }
4646+4747+ var task: HTTPTask {
4848+ switch self {
4949+ case .createRecord(let body), .deleteRecord(let body):
5050+ return .requestParameters(encoding: .jsonDataEncoding(data: body))
5151+5252+ case .getRecord(let repo, let collection, let rkey):
5353+ return .requestParameters(encoding: .urlEncoding(parameters: [
5454+ "repo": repo,
5555+ "collection": collection,
5656+ "rkey": rkey
5757+ ]))
5858+5959+ case .listRecords(let repo, let collection, let limit, let cursor):
6060+ var params: Parameters = ["repo": repo, "collection": collection, "limit": limit]
6161+ if let cursor { params["cursor"] = cursor }
6262+ return .requestParameters(encoding: .urlEncoding(parameters: params))
6363+ }
6464+ }
6565+6666+ var headers: HTTPHeaders? {
6767+ nil
6868+ }
6969+}
+471
Sources/bskyKit/RepoService.swift
···11+//
22+// RepoService.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+import CoreATProtocol
1010+1111+/// Service for repository operations (create, delete, update records)
1212+@APActor
1313+public struct RepoService: Sendable {
1414+ private let router: NetworkRouter<RepoAPI> = {
1515+ let router = NetworkRouter<RepoAPI>(decoder: .atDecoder)
1616+ router.delegate = APEnvironment.current.routerDelegate
1717+ return router
1818+ }()
1919+2020+ public init() {}
2121+2222+ // MARK: - Record Operations
2323+2424+ /// Creates a new record in the repository
2525+ public func createRecord(
2626+ repo: String,
2727+ collection: String,
2828+ record: [String: Any],
2929+ rkey: String? = nil
3030+ ) async throws -> CreateRecordResponse {
3131+ var body: [String: Any] = [
3232+ "repo": repo,
3333+ "collection": collection,
3434+ "record": record
3535+ ]
3636+ if let rkey { body["rkey"] = rkey }
3737+3838+ let data = try JSONSerialization.data(withJSONObject: body)
3939+ return try await router.execute(.createRecord(body: data))
4040+ }
4141+4242+ /// Deletes a record from the repository
4343+ public func deleteRecord(
4444+ repo: String,
4545+ collection: String,
4646+ rkey: String
4747+ ) async throws {
4848+ let body: [String: Any] = [
4949+ "repo": repo,
5050+ "collection": collection,
5151+ "rkey": rkey
5252+ ]
5353+ let data = try JSONSerialization.data(withJSONObject: body)
5454+ let _: EmptyResponse = try await router.execute(.deleteRecord(body: data))
5555+ }
5656+5757+ /// Gets a single record
5858+ public func getRecord(
5959+ repo: String,
6060+ collection: String,
6161+ rkey: String
6262+ ) async throws -> GetRecordResponse {
6363+ try await router.execute(.getRecord(repo: repo, collection: collection, rkey: rkey))
6464+ }
6565+6666+ /// Lists records in a collection
6767+ public func listRecords(
6868+ repo: String,
6969+ collection: String,
7070+ limit: Int = 50,
7171+ cursor: String? = nil
7272+ ) async throws -> ListRecordsResponse {
7373+ try await router.execute(.listRecords(repo: repo, collection: collection, limit: limit, cursor: cursor))
7474+ }
7575+7676+ // Note: uploadBlob deferred until CoreATProtocol is updated
7777+7878+ // MARK: - High-Level Operations
7979+8080+ /// Creates a new post
8181+ public func createPost(_ post: PostRecord, repo: String) async throws -> CreateRecordResponse {
8282+ try await createRecord(
8383+ repo: repo,
8484+ collection: "app.bsky.feed.post",
8585+ record: post.toRecord()
8686+ )
8787+ }
8888+8989+ /// Likes a post
9090+ public func like(uri: String, cid: String, repo: String) async throws -> CreateRecordResponse {
9191+ let record: [String: Any] = [
9292+ "$type": "app.bsky.feed.like",
9393+ "subject": ["uri": uri, "cid": cid],
9494+ "createdAt": ISO8601DateFormatter().string(from: Date())
9595+ ]
9696+ return try await createRecord(repo: repo, collection: "app.bsky.feed.like", record: record)
9797+ }
9898+9999+ /// Removes a like
100100+ public func unlike(uri: String, repo: String) async throws {
101101+ guard let rkey = extractRkey(from: uri) else {
102102+ throw RepoError.invalidUri(uri)
103103+ }
104104+ try await deleteRecord(repo: repo, collection: "app.bsky.feed.like", rkey: rkey)
105105+ }
106106+107107+ /// Reposts a post
108108+ public func repost(uri: String, cid: String, repo: String) async throws -> CreateRecordResponse {
109109+ let record: [String: Any] = [
110110+ "$type": "app.bsky.feed.repost",
111111+ "subject": ["uri": uri, "cid": cid],
112112+ "createdAt": ISO8601DateFormatter().string(from: Date())
113113+ ]
114114+ return try await createRecord(repo: repo, collection: "app.bsky.feed.repost", record: record)
115115+ }
116116+117117+ /// Removes a repost
118118+ public func unrepost(uri: String, repo: String) async throws {
119119+ guard let rkey = extractRkey(from: uri) else {
120120+ throw RepoError.invalidUri(uri)
121121+ }
122122+ try await deleteRecord(repo: repo, collection: "app.bsky.feed.repost", rkey: rkey)
123123+ }
124124+125125+ /// Follows a user
126126+ public func follow(did: String, repo: String) async throws -> CreateRecordResponse {
127127+ let record: [String: Any] = [
128128+ "$type": "app.bsky.graph.follow",
129129+ "subject": did,
130130+ "createdAt": ISO8601DateFormatter().string(from: Date())
131131+ ]
132132+ return try await createRecord(repo: repo, collection: "app.bsky.graph.follow", record: record)
133133+ }
134134+135135+ /// Unfollows a user
136136+ public func unfollow(uri: String, repo: String) async throws {
137137+ guard let rkey = extractRkey(from: uri) else {
138138+ throw RepoError.invalidUri(uri)
139139+ }
140140+ try await deleteRecord(repo: repo, collection: "app.bsky.graph.follow", rkey: rkey)
141141+ }
142142+143143+ /// Blocks a user
144144+ public func block(did: String, repo: String) async throws -> CreateRecordResponse {
145145+ let record: [String: Any] = [
146146+ "$type": "app.bsky.graph.block",
147147+ "subject": did,
148148+ "createdAt": ISO8601DateFormatter().string(from: Date())
149149+ ]
150150+ return try await createRecord(repo: repo, collection: "app.bsky.graph.block", record: record)
151151+ }
152152+153153+ /// Unblocks a user
154154+ public func unblock(uri: String, repo: String) async throws {
155155+ guard let rkey = extractRkey(from: uri) else {
156156+ throw RepoError.invalidUri(uri)
157157+ }
158158+ try await deleteRecord(repo: repo, collection: "app.bsky.graph.block", rkey: rkey)
159159+ }
160160+161161+ // MARK: - Private Helpers
162162+163163+ private func extractRkey(from uri: String) -> String? {
164164+ // AT URI format: at://did:plc:xxx/collection/rkey
165165+ uri.split(separator: "/").last.map(String.init)
166166+ }
167167+}
168168+169169+// MARK: - Response Types
170170+171171+public struct CreateRecordResponse: Codable, Sendable {
172172+ public let uri: String
173173+ public let cid: String
174174+}
175175+176176+public struct GetRecordResponse: Codable, Sendable {
177177+ public let uri: String
178178+ public let cid: String?
179179+ public let value: RecordValue
180180+}
181181+182182+public struct RecordValue: Codable, Sendable {
183183+ public let type: String?
184184+ public let text: String?
185185+ public let createdAt: String?
186186+187187+ enum CodingKeys: String, CodingKey {
188188+ case type = "$type"
189189+ case text, createdAt
190190+ }
191191+}
192192+193193+public struct ListRecordsResponse: Codable, Sendable {
194194+ public let records: [RecordItem]
195195+ public let cursor: String?
196196+}
197197+198198+public struct RecordItem: Codable, Sendable {
199199+ public let uri: String
200200+ public let cid: String
201201+ public let value: RecordValue
202202+}
203203+204204+public struct BlobResponse: Codable, Sendable {
205205+ public let blob: BlobRef
206206+}
207207+208208+public struct BlobRef: Codable, Sendable {
209209+ public let type: String
210210+ public let ref: BlobLink
211211+ public let mimeType: String
212212+ public let size: Int
213213+214214+ enum CodingKeys: String, CodingKey {
215215+ case type = "$type"
216216+ case ref, mimeType, size
217217+ }
218218+}
219219+220220+public struct BlobLink: Codable, Sendable {
221221+ public let link: String
222222+223223+ enum CodingKeys: String, CodingKey {
224224+ case link = "$link"
225225+ }
226226+}
227227+228228+// MARK: - Post Record
229229+230230+/// A record for creating a post
231231+public struct PostRecord: Sendable {
232232+ public let text: String
233233+ public let facets: [RichTextFacet]?
234234+ public let reply: ReplyRef?
235235+ public let embed: PostEmbed?
236236+ public let langs: [String]?
237237+ public let createdAt: Date
238238+239239+ public init(
240240+ text: String,
241241+ facets: [RichTextFacet]? = nil,
242242+ reply: ReplyRef? = nil,
243243+ embed: PostEmbed? = nil,
244244+ langs: [String]? = nil,
245245+ createdAt: Date = Date()
246246+ ) {
247247+ self.text = text
248248+ self.facets = facets
249249+ self.reply = reply
250250+ self.embed = embed
251251+ self.langs = langs
252252+ self.createdAt = createdAt
253253+ }
254254+255255+ /// Creates a PostRecord with auto-detected facets
256256+ public static func create(text: String, reply: ReplyRef? = nil, embed: PostEmbed? = nil, langs: [String]? = nil) -> PostRecord {
257257+ let richText = RichText.detect(in: text)
258258+ return PostRecord(
259259+ text: text,
260260+ facets: richText.facets.isEmpty ? nil : richText.facets,
261261+ reply: reply,
262262+ embed: embed,
263263+ langs: langs
264264+ )
265265+ }
266266+267267+ func toRecord() -> [String: Any] {
268268+ let formatter = ISO8601DateFormatter()
269269+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
270270+271271+ var record: [String: Any] = [
272272+ "$type": "app.bsky.feed.post",
273273+ "text": text,
274274+ "createdAt": formatter.string(from: createdAt)
275275+ ]
276276+277277+ if let facets, !facets.isEmpty {
278278+ record["facets"] = facets.map { facet in
279279+ var dict: [String: Any] = [
280280+ "index": [
281281+ "byteStart": facet.index.byteStart,
282282+ "byteEnd": facet.index.byteEnd
283283+ ]
284284+ ]
285285+ dict["features"] = facet.features.map { feature -> [String: Any] in
286286+ switch feature {
287287+ case .link(let link):
288288+ return ["$type": "app.bsky.richtext.facet#link", "uri": link.uri]
289289+ case .mention(let mention):
290290+ return ["$type": "app.bsky.richtext.facet#mention", "did": mention.did ?? ""]
291291+ case .tag(let tag):
292292+ return ["$type": "app.bsky.richtext.facet#tag", "tag": tag.tag]
293293+ }
294294+ }
295295+ return dict
296296+ }
297297+ }
298298+299299+ if let reply {
300300+ record["reply"] = [
301301+ "root": ["uri": reply.root.uri, "cid": reply.root.cid],
302302+ "parent": ["uri": reply.parent.uri, "cid": reply.parent.cid]
303303+ ]
304304+ }
305305+306306+ if let embed {
307307+ record["embed"] = embed.toRecord()
308308+ }
309309+310310+ if let langs {
311311+ record["langs"] = langs
312312+ }
313313+314314+ return record
315315+ }
316316+}
317317+318318+/// Reference to a post for replies
319319+public struct ReplyRef: Sendable {
320320+ public let root: PostRef
321321+ public let parent: PostRef
322322+323323+ public init(root: PostRef, parent: PostRef) {
324324+ self.root = root
325325+ self.parent = parent
326326+ }
327327+}
328328+329329+/// Reference to a post (URI + CID)
330330+public struct PostRef: Sendable {
331331+ public let uri: String
332332+ public let cid: String
333333+334334+ public init(uri: String, cid: String) {
335335+ self.uri = uri
336336+ self.cid = cid
337337+ }
338338+}
339339+340340+/// Embed types for posts
341341+public enum PostEmbed: Sendable {
342342+ case images([ImageEmbed])
343343+ case external(ExternalEmbed)
344344+ case record(RecordEmbed)
345345+ case recordWithMedia(RecordEmbed, [ImageEmbed])
346346+347347+ func toRecord() -> [String: Any] {
348348+ switch self {
349349+ case .images(let images):
350350+ return [
351351+ "$type": "app.bsky.embed.images",
352352+ "images": images.map { $0.toRecord() }
353353+ ]
354354+ case .external(let external):
355355+ return [
356356+ "$type": "app.bsky.embed.external",
357357+ "external": external.toRecord()
358358+ ]
359359+ case .record(let record):
360360+ return [
361361+ "$type": "app.bsky.embed.record",
362362+ "record": ["uri": record.uri, "cid": record.cid]
363363+ ]
364364+ case .recordWithMedia(let record, let images):
365365+ return [
366366+ "$type": "app.bsky.embed.recordWithMedia",
367367+ "record": ["record": ["uri": record.uri, "cid": record.cid]],
368368+ "media": [
369369+ "$type": "app.bsky.embed.images",
370370+ "images": images.map { $0.toRecord() }
371371+ ]
372372+ ]
373373+ }
374374+ }
375375+}
376376+377377+/// Image embed
378378+public struct ImageEmbed: Sendable {
379379+ public let image: BlobRef
380380+ public let alt: String
381381+ public let aspectRatio: AspectRatio?
382382+383383+ public init(image: BlobRef, alt: String, aspectRatio: AspectRatio? = nil) {
384384+ self.image = image
385385+ self.alt = alt
386386+ self.aspectRatio = aspectRatio
387387+ }
388388+389389+ func toRecord() -> [String: Any] {
390390+ var record: [String: Any] = [
391391+ "image": [
392392+ "$type": image.type,
393393+ "ref": ["$link": image.ref.link],
394394+ "mimeType": image.mimeType,
395395+ "size": image.size
396396+ ],
397397+ "alt": alt
398398+ ]
399399+ if let aspectRatio {
400400+ record["aspectRatio"] = ["width": aspectRatio.width, "height": aspectRatio.height]
401401+ }
402402+ return record
403403+ }
404404+}
405405+406406+/// Aspect ratio for images
407407+public struct AspectRatio: Sendable {
408408+ public let width: Int
409409+ public let height: Int
410410+411411+ public init(width: Int, height: Int) {
412412+ self.width = width
413413+ self.height = height
414414+ }
415415+}
416416+417417+/// External link embed
418418+public struct ExternalEmbed: Sendable {
419419+ public let uri: String
420420+ public let title: String
421421+ public let description: String
422422+ public let thumb: BlobRef?
423423+424424+ public init(uri: String, title: String, description: String, thumb: BlobRef? = nil) {
425425+ self.uri = uri
426426+ self.title = title
427427+ self.description = description
428428+ self.thumb = thumb
429429+ }
430430+431431+ func toRecord() -> [String: Any] {
432432+ var record: [String: Any] = [
433433+ "uri": uri,
434434+ "title": title,
435435+ "description": description
436436+ ]
437437+ if let thumb {
438438+ record["thumb"] = [
439439+ "$type": thumb.type,
440440+ "ref": ["$link": thumb.ref.link],
441441+ "mimeType": thumb.mimeType,
442442+ "size": thumb.size
443443+ ]
444444+ }
445445+ return record
446446+ }
447447+}
448448+449449+/// Record embed (quote post)
450450+public struct RecordEmbed: Sendable {
451451+ public let uri: String
452452+ public let cid: String
453453+454454+ public init(uri: String, cid: String) {
455455+ self.uri = uri
456456+ self.cid = cid
457457+ }
458458+}
459459+460460+// MARK: - Errors
461461+462462+public enum RepoError: Error, LocalizedError {
463463+ case invalidUri(String)
464464+465465+ public var errorDescription: String? {
466466+ switch self {
467467+ case .invalidUri(let uri):
468468+ return "Invalid AT URI: \(uri)"
469469+ }
470470+ }
471471+}
+407
Sources/bskyKit/RichText/RichText.swift
···11+//
22+// RichText.swift
33+// bskyKit
44+//
55+// Created by Thomas Rademaker on 01/02/2026.
66+//
77+88+import Foundation
99+1010+/// Handles rich text with facets for AT Protocol.
1111+///
1212+/// `RichText` provides utilities for creating and parsing rich text content
1313+/// that includes mentions, links, and hashtags. It handles the critical conversion
1414+/// between character indices and byte indices required by the AT Protocol.
1515+///
1616+/// ## Overview
1717+///
1818+/// Bluesky uses "facets" to mark up rich text. Each facet identifies a span of text
1919+/// using **byte indices** (not character indices) and associates it with a feature
2020+/// type like mention, link, or hashtag.
2121+///
2222+/// ## Auto-Detection
2323+///
2424+/// The easiest way to create rich text is with automatic detection:
2525+///
2626+/// ```swift
2727+/// let text = "Hey @alice.bsky.social check https://example.com #atproto"
2828+/// let richText = RichText.detect(in: text)
2929+///
3030+/// print("Found \(richText.facets.count) facets")
3131+/// ```
3232+///
3333+/// ## Manual Creation
3434+///
3535+/// For precise control, create facets manually:
3636+///
3737+/// ```swift
3838+/// let facet = RichTextFacet(
3939+/// index: RichTextFacetIndex(byteStart: 0, byteEnd: 10),
4040+/// features: [.link(RichTextLink(uri: "https://example.com"))]
4141+/// )
4242+/// let richText = RichText(text: "Visit here", facets: [facet])
4343+/// ```
4444+///
4545+/// ## Byte Index Handling
4646+///
4747+/// Always use ``byteIndex(from:)`` when converting from String indices:
4848+///
4949+/// ```swift
5050+/// let text = "Hello 👋" // Emoji takes 4 bytes
5151+/// let richText = RichText(text: text)
5252+/// let byteIdx = richText.byteIndex(from: text.endIndex) // Returns 10, not 7
5353+/// ```
5454+///
5555+/// ## Topics
5656+///
5757+/// ### Creating Rich Text
5858+/// - ``init(text:facets:)``
5959+/// - ``detect(in:)``
6060+/// - ``detectFacets()``
6161+///
6262+/// ### Converting Indices
6363+/// - ``byteIndex(from:)``
6464+/// - ``characterIndex(from:)``
6565+///
6666+/// ### API Conversion
6767+/// - ``toAPIFacets()``
6868+public struct RichText: Sendable {
6969+ /// The plain text content.
7070+ public let text: String
7171+7272+ /// Detected facets marking mentions, links, and hashtags.
7373+ public private(set) var facets: [RichTextFacet]
7474+7575+ /// Creates a RichText with the given text and optional facets.
7676+ /// - Parameters:
7777+ /// - text: The plain text content.
7878+ /// - facets: Pre-computed facets (default: empty).
7979+ public init(text: String, facets: [RichTextFacet] = []) {
8080+ self.text = text
8181+ self.facets = facets
8282+ }
8383+8484+ /// Creates RichText with auto-detected mentions, links, and hashtags.
8585+ ///
8686+ /// This is the recommended way to create rich text content:
8787+ ///
8888+ /// ```swift
8989+ /// let richText = RichText.detect(in: "Hey @alice check https://example.com #cool")
9090+ /// // richText.facets contains 3 facets
9191+ /// ```
9292+ ///
9393+ /// - Parameter text: The text to analyze for facets.
9494+ /// - Returns: A RichText instance with detected facets.
9595+ public static func detect(in text: String) -> RichText {
9696+ var richText = RichText(text: text)
9797+ richText.detectFacets()
9898+ return richText
9999+ }
100100+101101+ /// Detects and populates facets for links, mentions, and hashtags.
102102+ ///
103103+ /// Called automatically by ``detect(in:)``. Call manually if you need
104104+ /// to re-detect facets after modifying the text.
105105+ public mutating func detectFacets() {
106106+ facets = []
107107+ detectLinks()
108108+ detectMentions()
109109+ detectHashtags()
110110+ facets.sort { $0.index.byteStart < $1.index.byteStart }
111111+ }
112112+113113+ /// Converts a Swift String.Index to a UTF-8 byte index.
114114+ ///
115115+ /// Use this when you have a character position and need the byte offset
116116+ /// for creating facets.
117117+ ///
118118+ /// ```swift
119119+ /// let text = "Hi 👋"
120120+ /// let richText = RichText(text: text)
121121+ /// let byteIdx = richText.byteIndex(from: text.endIndex) // 7 (not 4)
122122+ /// ```
123123+ ///
124124+ /// - Parameter characterIndex: A String.Index in the text.
125125+ /// - Returns: The byte offset in UTF-8 encoding.
126126+ public func byteIndex(from characterIndex: String.Index) -> Int {
127127+ text.utf8.distance(from: text.startIndex, to: characterIndex)
128128+ }
129129+130130+ /// Converts a UTF-8 byte index to a Swift String.Index.
131131+ ///
132132+ /// Use this when you have a byte offset from a facet and need to
133133+ /// extract the corresponding substring.
134134+ ///
135135+ /// - Parameter byteIndex: The byte offset in UTF-8 encoding.
136136+ /// - Returns: The corresponding String.Index, or nil if invalid.
137137+ public func characterIndex(from byteIndex: Int) -> String.Index? {
138138+ var currentByte = 0
139139+ for index in text.indices {
140140+ if currentByte == byteIndex {
141141+ return index
142142+ }
143143+ let char = text[index]
144144+ currentByte += char.utf8.count
145145+ }
146146+ return currentByte == byteIndex ? text.endIndex : nil
147147+ }
148148+149149+ // MARK: - Private Detection Methods
150150+151151+ private mutating func detectLinks() {
152152+ let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
153153+ let range = NSRange(text.startIndex..., in: text)
154154+155155+ detector?.enumerateMatches(in: text, options: [], range: range) { result, _, _ in
156156+ guard let result = result,
157157+ let range = Range(result.range, in: text),
158158+ let url = result.url else { return }
159159+160160+ let byteStart = byteIndex(from: range.lowerBound)
161161+ let byteEnd = byteIndex(from: range.upperBound)
162162+163163+ let facet = RichTextFacet(
164164+ index: RichTextFacetIndex(byteStart: byteStart, byteEnd: byteEnd),
165165+ features: [.link(RichTextLink(uri: url.absoluteString))]
166166+ )
167167+ facets.append(facet)
168168+ }
169169+ }
170170+171171+ private mutating func detectMentions() {
172172+ // Match @handle pattern (alphanumeric, dots, hyphens, underscores)
173173+ // Handle format: @username.bsky.social or @did:plc:xxx
174174+ let pattern = #"@([a-zA-Z0-9]([a-zA-Z0-9._-])*[a-zA-Z0-9]|[a-zA-Z0-9])"#
175175+ guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
176176+177177+ let range = NSRange(text.startIndex..., in: text)
178178+ regex.enumerateMatches(in: text, options: [], range: range) { result, _, _ in
179179+ guard let result = result,
180180+ let range = Range(result.range, in: text) else { return }
181181+182182+ let handle = String(text[range].dropFirst()) // Remove @
183183+ let byteStart = byteIndex(from: range.lowerBound)
184184+ let byteEnd = byteIndex(from: range.upperBound)
185185+186186+ let facet = RichTextFacet(
187187+ index: RichTextFacetIndex(byteStart: byteStart, byteEnd: byteEnd),
188188+ features: [.mention(RichTextMention(handle: handle))]
189189+ )
190190+ facets.append(facet)
191191+ }
192192+ }
193193+194194+ private mutating func detectHashtags() {
195195+ // Match #hashtag pattern (alphanumeric and underscores, no leading numbers)
196196+ let pattern = #"#([a-zA-Z_][a-zA-Z0-9_]*)"#
197197+ guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
198198+199199+ let range = NSRange(text.startIndex..., in: text)
200200+ regex.enumerateMatches(in: text, options: [], range: range) { result, _, _ in
201201+ guard let result = result,
202202+ let range = Range(result.range, in: text) else { return }
203203+204204+ let tag = String(text[range].dropFirst()) // Remove #
205205+ let byteStart = byteIndex(from: range.lowerBound)
206206+ let byteEnd = byteIndex(from: range.upperBound)
207207+208208+ let facet = RichTextFacet(
209209+ index: RichTextFacetIndex(byteStart: byteStart, byteEnd: byteEnd),
210210+ features: [.tag(RichTextTag(tag: tag))]
211211+ )
212212+ facets.append(facet)
213213+ }
214214+ }
215215+}
216216+217217+// MARK: - Facet Types
218218+219219+/// A facet marking a segment of rich text with special meaning.
220220+///
221221+/// Facets identify spans of text that should be rendered as interactive
222222+/// elements like mentions, links, or hashtags.
223223+///
224224+/// ## Example
225225+///
226226+/// ```swift
227227+/// let facet = RichTextFacet(
228228+/// index: RichTextFacetIndex(byteStart: 0, byteEnd: 19),
229229+/// features: [.mention(RichTextMention(handle: "alice.bsky.social"))]
230230+/// )
231231+/// ```
232232+public struct RichTextFacet: Codable, Sendable {
233233+ /// The byte range of this facet in the text.
234234+ public let index: RichTextFacetIndex
235235+236236+ /// The features (link, mention, tag) for this facet.
237237+ public let features: [RichTextFeature]
238238+239239+ /// Creates a facet with the given index and features.
240240+ public init(index: RichTextFacetIndex, features: [RichTextFeature]) {
241241+ self.index = index
242242+ self.features = features
243243+ }
244244+}
245245+246246+/// Byte indices marking the start and end of a facet.
247247+///
248248+/// Indices are UTF-8 byte offsets, not character counts.
249249+/// Use ``RichText/byteIndex(from:)`` to convert from String indices.
250250+public struct RichTextFacetIndex: Codable, Sendable {
251251+ /// The starting byte offset (inclusive).
252252+ public let byteStart: Int
253253+254254+ /// The ending byte offset (exclusive).
255255+ public let byteEnd: Int
256256+257257+ /// Creates an index with the given byte range.
258258+ public init(byteStart: Int, byteEnd: Int) {
259259+ self.byteStart = byteStart
260260+ self.byteEnd = byteEnd
261261+ }
262262+}
263263+264264+/// A feature type within a facet.
265265+///
266266+/// Each facet can have one or more features identifying what kind
267267+/// of rich content it represents.
268268+public enum RichTextFeature: Codable, Sendable {
269269+ /// A clickable link to a URL.
270270+ case link(RichTextLink)
271271+272272+ /// A mention of another user.
273273+ case mention(RichTextMention)
274274+275275+ /// A hashtag for discovery.
276276+ case tag(RichTextTag)
277277+278278+ enum CodingKeys: String, CodingKey {
279279+ case type = "$type"
280280+ case uri
281281+ case did
282282+ case tag
283283+ }
284284+285285+ public init(from decoder: Decoder) throws {
286286+ let container = try decoder.container(keyedBy: CodingKeys.self)
287287+ let type = try container.decode(String.self, forKey: .type)
288288+289289+ switch type {
290290+ case "app.bsky.richtext.facet#link":
291291+ let uri = try container.decode(String.self, forKey: .uri)
292292+ self = .link(RichTextLink(uri: uri))
293293+ case "app.bsky.richtext.facet#mention":
294294+ let did = try container.decode(String.self, forKey: .did)
295295+ self = .mention(RichTextMention(did: did))
296296+ case "app.bsky.richtext.facet#tag":
297297+ let tag = try container.decode(String.self, forKey: .tag)
298298+ self = .tag(RichTextTag(tag: tag))
299299+ default:
300300+ throw DecodingError.dataCorrupted(
301301+ DecodingError.Context(
302302+ codingPath: decoder.codingPath,
303303+ debugDescription: "Unknown facet type: \(type)"
304304+ )
305305+ )
306306+ }
307307+ }
308308+309309+ public func encode(to encoder: Encoder) throws {
310310+ var container = encoder.container(keyedBy: CodingKeys.self)
311311+ switch self {
312312+ case .link(let link):
313313+ try container.encode("app.bsky.richtext.facet#link", forKey: .type)
314314+ try container.encode(link.uri, forKey: .uri)
315315+ case .mention(let mention):
316316+ try container.encode("app.bsky.richtext.facet#mention", forKey: .type)
317317+ try container.encode(mention.did ?? mention.handle, forKey: .did)
318318+ case .tag(let tag):
319319+ try container.encode("app.bsky.richtext.facet#tag", forKey: .type)
320320+ try container.encode(tag.tag, forKey: .tag)
321321+ }
322322+ }
323323+}
324324+325325+/// A link facet feature representing a clickable URL.
326326+///
327327+/// When parsed from API responses, contains the full URI.
328328+/// When creating new posts, provide the destination URL.
329329+public struct RichTextLink: Codable, Sendable {
330330+ /// The destination URL.
331331+ public let uri: String
332332+333333+ /// Creates a link feature with the given URI.
334334+ public init(uri: String) {
335335+ self.uri = uri
336336+ }
337337+}
338338+339339+/// A mention facet feature representing a reference to another user.
340340+///
341341+/// When detecting mentions, `handle` is populated but `did` is nil.
342342+/// Before posting, you should resolve the handle to a DID.
343343+public struct RichTextMention: Codable, Sendable {
344344+ /// The handle being mentioned (e.g., "alice.bsky.social").
345345+ /// Populated during detection, before DID resolution.
346346+ public let handle: String?
347347+348348+ /// The DID of the mentioned user.
349349+ /// Required for posting; resolve from handle if needed.
350350+ public let did: String?
351351+352352+ /// Creates a mention feature.
353353+ /// - Parameters:
354354+ /// - handle: The user's handle (before resolution).
355355+ /// - did: The user's DID (after resolution).
356356+ public init(handle: String? = nil, did: String? = nil) {
357357+ self.handle = handle
358358+ self.did = did
359359+ }
360360+}
361361+362362+/// A hashtag facet feature for content discovery.
363363+///
364364+/// Tags enable searching and browsing posts by topic.
365365+public struct RichTextTag: Codable, Sendable {
366366+ /// The tag text without the leading '#'.
367367+ public let tag: String
368368+369369+ /// Creates a tag feature with the given text.
370370+ /// - Parameter tag: The tag text (without '#').
371371+ public init(tag: String) {
372372+ self.tag = tag
373373+ }
374374+}
375375+376376+// MARK: - Extensions
377377+378378+extension RichText {
379379+ /// Returns facets in the format expected by the API
380380+ public func toAPIFacets() -> [[String: Any]] {
381381+ facets.map { facet in
382382+ var dict: [String: Any] = [
383383+ "index": [
384384+ "byteStart": facet.index.byteStart,
385385+ "byteEnd": facet.index.byteEnd
386386+ ]
387387+ ]
388388+389389+ let features: [[String: Any]] = facet.features.map { feature in
390390+ switch feature {
391391+ case .link(let link):
392392+ return ["$type": "app.bsky.richtext.facet#link", "uri": link.uri]
393393+ case .mention(let mention):
394394+ if let did = mention.did {
395395+ return ["$type": "app.bsky.richtext.facet#mention", "did": did]
396396+ }
397397+ return [:]
398398+ case .tag(let tag):
399399+ return ["$type": "app.bsky.richtext.facet#tag", "tag": tag.tag]
400400+ }
401401+ }
402402+403403+ dict["features"] = features
404404+ return dict
405405+ }
406406+ }
407407+}
+2
Sources/bskyKit/bskyKit.swift
···11+// The Swift Programming Language
22+// https://docs.swift.org/swift-book
···11+import Testing
22+@testable import bskyKit
33+44+@Suite("RichText Tests")
55+struct RichTextTests {
66+77+ // MARK: - Basic Text
88+99+ @Test("Plain text has no facets")
1010+ func plainTextNoFacets() {
1111+ let richText = RichText.detect(in: "Hello, world!")
1212+ #expect(richText.text == "Hello, world!")
1313+ #expect(richText.facets.isEmpty)
1414+ }
1515+1616+ // MARK: - Link Detection
1717+1818+ @Test("Detects simple URL")
1919+ func detectsSimpleURL() {
2020+ let richText = RichText.detect(in: "Check out https://example.com today")
2121+ #expect(richText.facets.count == 1)
2222+2323+ let facet = richText.facets[0]
2424+ #expect(facet.index.byteStart == 10)
2525+ #expect(facet.index.byteEnd == 29)
2626+2727+ if case .link(let link) = facet.features[0] {
2828+ #expect(link.uri == "https://example.com")
2929+ } else {
3030+ Issue.record("Expected link facet")
3131+ }
3232+ }
3333+3434+ @Test("Detects multiple URLs")
3535+ func detectsMultipleURLs() {
3636+ let richText = RichText.detect(in: "Visit https://a.com and https://b.com")
3737+ #expect(richText.facets.count == 2)
3838+ }
3939+4040+ // MARK: - Mention Detection
4141+4242+ @Test("Detects simple mention")
4343+ func detectsSimpleMention() {
4444+ let richText = RichText.detect(in: "Hello @alice.bsky.social!")
4545+ #expect(richText.facets.count == 1)
4646+4747+ let facet = richText.facets[0]
4848+ if case .mention(let mention) = facet.features[0] {
4949+ #expect(mention.handle == "alice.bsky.social")
5050+ } else {
5151+ Issue.record("Expected mention facet")
5252+ }
5353+ }
5454+5555+ @Test("Detects mention at start")
5656+ func detectsMentionAtStart() {
5757+ let richText = RichText.detect(in: "@alice hello")
5858+ #expect(richText.facets.count == 1)
5959+ #expect(richText.facets[0].index.byteStart == 0)
6060+ }
6161+6262+ @Test("Detects multiple mentions")
6363+ func detectsMultipleMentions() {
6464+ let richText = RichText.detect(in: "Hey @alice and @bob")
6565+ #expect(richText.facets.count == 2)
6666+ }
6767+6868+ // MARK: - Hashtag Detection
6969+7070+ @Test("Detects simple hashtag")
7171+ func detectsSimpleHashtag() {
7272+ let richText = RichText.detect(in: "Love this #atproto")
7373+ #expect(richText.facets.count == 1)
7474+7575+ let facet = richText.facets[0]
7676+ if case .tag(let tag) = facet.features[0] {
7777+ #expect(tag.tag == "atproto")
7878+ } else {
7979+ Issue.record("Expected tag facet")
8080+ }
8181+ }
8282+8383+ @Test("Detects hashtag with underscores")
8484+ func detectsHashtagWithUnderscores() {
8585+ let richText = RichText.detect(in: "Check #my_cool_tag")
8686+ #expect(richText.facets.count == 1)
8787+8888+ if case .tag(let tag) = richText.facets[0].features[0] {
8989+ #expect(tag.tag == "my_cool_tag")
9090+ } else {
9191+ Issue.record("Expected tag facet")
9292+ }
9393+ }
9494+9595+ @Test("Does not detect hashtag starting with number")
9696+ func noHashtagStartingWithNumber() {
9797+ let richText = RichText.detect(in: "Not a tag #123abc")
9898+ // Should not detect #123abc as a valid hashtag
9999+ let tagFacets = richText.facets.filter {
100100+ if case .tag = $0.features[0] { return true }
101101+ return false
102102+ }
103103+ #expect(tagFacets.isEmpty)
104104+ }
105105+106106+ // MARK: - Mixed Content
107107+108108+ @Test("Detects mixed content")
109109+ func detectsMixedContent() {
110110+ let richText = RichText.detect(in: "Hey @alice check https://example.com #cool")
111111+ #expect(richText.facets.count == 3)
112112+113113+ // Facets should be sorted by byte position
114114+ var hasMention = false
115115+ var hasLink = false
116116+ var hasTag = false
117117+118118+ for facet in richText.facets {
119119+ switch facet.features[0] {
120120+ case .mention: hasMention = true
121121+ case .link: hasLink = true
122122+ case .tag: hasTag = true
123123+ }
124124+ }
125125+126126+ #expect(hasMention)
127127+ #expect(hasLink)
128128+ #expect(hasTag)
129129+ }
130130+131131+ // MARK: - Byte Index Conversion
132132+133133+ @Test("Byte index for ASCII")
134134+ func byteIndexASCII() {
135135+ let richText = RichText(text: "Hello")
136136+ let index = richText.text.index(richText.text.startIndex, offsetBy: 3)
137137+ #expect(richText.byteIndex(from: index) == 3)
138138+ }
139139+140140+ @Test("Byte index for emoji")
141141+ func byteIndexEmoji() {
142142+ // Emoji takes 4 bytes in UTF-8
143143+ let richText = RichText(text: "Hi 👋 there")
144144+ let index = richText.text.index(richText.text.startIndex, offsetBy: 5) // After emoji
145145+ // "Hi " = 3 bytes, "👋" = 4 bytes, " " = 1 byte... index 5 is 't'
146146+ // Actually "Hi " is 3 chars/bytes, emoji is 1 char but 4 bytes
147147+ // Index 5 (char index) = "t" which comes after "Hi 👋 " = 3 + 4 + 1 = 8 bytes
148148+ #expect(richText.byteIndex(from: index) == 8)
149149+ }
150150+151151+ @Test("Character index from byte index")
152152+ func characterIndexFromByte() {
153153+ let richText = RichText(text: "Hello")
154154+ let charIndex = richText.characterIndex(from: 3)
155155+ #expect(charIndex != nil)
156156+ #expect(richText.text[charIndex!] == "l")
157157+ }
158158+159159+ // MARK: - Edge Cases
160160+161161+ @Test("Empty text")
162162+ func emptyText() {
163163+ let richText = RichText.detect(in: "")
164164+ #expect(richText.text.isEmpty)
165165+ #expect(richText.facets.isEmpty)
166166+ }
167167+168168+ @Test("URL at end without space")
169169+ func urlAtEndNoSpace() {
170170+ let richText = RichText.detect(in: "Visit https://example.com")
171171+ #expect(richText.facets.count == 1)
172172+ }
173173+174174+ @Test("Multiple spaces between mentions")
175175+ func multipleSpacesBetweenMentions() {
176176+ let richText = RichText.detect(in: "@alice @bob")
177177+ #expect(richText.facets.count == 2)
178178+ }
179179+}
+6
Tests/bskyKitTests/bskyKitTests.swift
···11+import Testing
22+@testable import bskyKit
33+44+@Test func example() async throws {
55+ // Write your test here and use APIs like `#expect(...)` to check expected conditions.
66+}