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