From 70ab6ca677854540a69c17f6b5fccf75fe41891b Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Sat, 7 Feb 2026 18:32:00 -0500 Subject: [PATCH] update readme and docs --- README.md | 33 +++++++++- .../Documentation.docc/GettingStarted.md | 4 +- .../Documentation.docc/Notifications.md | 47 +++++++++++--- .../Documentation.docc/RichTextGuide.md | 3 + .../Documentation.docc/SocialActions.md | 40 ++++++++++++ .../bskyKit/Documentation.docc/SocialGraph.md | 45 ++++++++++++++ .../Documentation.docc/TimelineAndFeeds.md | 61 +++++++++++++------ .../Documentation.docc/WorkingWithProfiles.md | 16 +++++ Sources/bskyKit/Documentation.docc/bskyKit.md | 4 +- 9 files changed, 218 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 440b161..171b08b 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,13 @@ A Swift SDK for building [Bluesky](https://bsky.app) clients on Apple platforms. bskyKit implements the `app.bsky.*` lexicons for the AT Protocol, giving you everything needed to build a full-featured Bluesky client: - **Read Operations** - Fetch timelines, profiles, posts, threads, notifications, and social graphs -- **Write Operations** - Create posts, likes, reposts, follows, and blocks +- **Write Operations** - Create posts, likes, reposts, follows, blocks, and low-level repo writes - **Rich Text** - Automatic detection and creation of mentions, links, and hashtags with proper byte indexing -- **Type Safety** - Fully typed models for all API responses with `Codable` and `Sendable` conformance +- **Type Safety** - Typed models for stable endpoints plus `JSONValue` for rapidly evolving/unspecced payloads - **SwiftUI Ready** - Models conform to `Identifiable` for seamless use in SwiftUI lists +`BskyService` now covers all current `app.bsky` query/procedure lexicons, including actor/feed/graph/notification plus age-assurance, unspecced, bookmark, and video endpoints. + Built on [CoreATProtocol](https://tangled.org/@sparrowtek.com/CoreATProtocol) for networking, authentication, and token management. ## Requirements @@ -120,19 +122,28 @@ try await repo.follow(did: "did:plc:someone", repo: myDID) |--------|-------------| | `getProfile(for:)` | Fetch a user profile by handle or DID | | `getProfiles(for:)` | Fetch multiple profiles in one request | +| `getSuggestions(limit:cursor:)` | Get actor suggestions | | `getPreferences()` | Get authenticated user's preferences | | `searchActors(query:limit:)` | Search users by name/handle/bio | | `searchActorsTypeahead(query:limit:)` | Fast search for autocomplete | +| `putPreferences(_:)` | Update actor preferences | #### Feed Operations | Method | Description | |--------|-------------| | `getTimeline(limit:cursor:)` | Get home timeline | +| `getFeed(feed:limit:cursor:)` | Get posts from a feed generator | | `getAuthorFeed(for:limit:cursor:)` | Get a user's posts | | `getPostThread(uri:depth:)` | Get post with replies | | `getPosts(uris:)` | Fetch multiple posts by URI | +| `searchPosts(...)` | Search posts | +| `getQuotes(uri:cid:limit:cursor:)` | Get quotes of a post | +| `getListFeed(list:limit:cursor:)` | Get posts from a list feed | +| `getActorFeeds(for:limit:cursor:)` | Get feed generators by actor | +| `getFeedGenerator(feed:)` | Get one feed generator | | `getFeedGenerators(for:)` | Get custom feed info | +| `getSuggestedFeeds(limit:cursor:)` | Get suggested feed generators | | `getLikes(uri:limit:cursor:)` | Get users who liked a post | | `getRepostedBy(uri:limit:cursor:)` | Get users who reposted | @@ -144,6 +155,10 @@ try await repo.follow(did: "did:plc:someone", repo: myDID) | `getFollowers(for:limit:cursor:)` | Get a user's followers | | `getBlocks(limit:cursor:)` | Get your blocked accounts | | `getMutes(limit:cursor:)` | Get your muted accounts | +| `getRelationships(for:others:)` | Get relationship state for actors | +| `muteActor(_:)` / `unmuteActor(_:)` | Mute or unmute actor | +| `muteThread(root:)` / `unmuteThread(root:)` | Mute or unmute thread | +| `muteActorList(_:)` / `unmuteActorList(_:)` | Mute or unmute actor list | #### Notification Operations @@ -152,6 +167,9 @@ try await repo.follow(did: "did:plc:someone", repo: myDID) | `listNotifications(limit:cursor:)` | Get notifications | | `getUnreadCount()` | Get unread notification count | | `updateSeen(at:)` | Mark notifications as read | +| `getNotificationPreferences()` | Read notification preferences | +| `putNotificationPreferences(priority:)` | Update legacy notification preferences | +| `putNotificationPreferencesV2(_:)` | Update v2 notification preferences | ### RepoService (Write Operations) @@ -178,9 +196,15 @@ unblock(uri:repo:) ```swift createRecord(repo:collection:record:rkey:) -> CreateRecordResponse +putRecord(repo:collection:rkey:record:validate:swapRecord:swapCommit:) -> PutRecordResponse +applyWrites(repo:writes:validate:swapCommit:) -> ApplyWritesResponse deleteRecord(repo:collection:rkey:) +describeRepo(repo:) -> DescribeRepoResponse getRecord(repo:collection:rkey:) -> GetRecordResponse listRecords(repo:collection:limit:cursor:) -> ListRecordsResponse +listMissingBlobs(limit:cursor:) -> ListMissingBlobsResponse +importRepo(car:) +uploadBlob(data:mimeType:) -> BlobResponse ``` ## Rich Text @@ -215,6 +239,8 @@ for facet in richText.facets { print("Link to: \(link.uri)") case .tag(let tag): print("Hashtag: #\(tag.tag)") + case .unknown: + break } } ``` @@ -314,7 +340,7 @@ All models are `Codable`, `Sendable`, and most are `Identifiable` for SwiftUI co ### Notification Reasons ```swift -public enum NotificationReason: String { +public enum NotificationReason: Codable, Sendable, Equatable, Hashable { case like case repost case follow @@ -322,6 +348,7 @@ public enum NotificationReason: String { case reply case quote case starterpackJoined + case unknown(String) } ``` diff --git a/Sources/bskyKit/Documentation.docc/GettingStarted.md b/Sources/bskyKit/Documentation.docc/GettingStarted.md index 10ff280..eaf0981 100644 --- a/Sources/bskyKit/Documentation.docc/GettingStarted.md +++ b/Sources/bskyKit/Documentation.docc/GettingStarted.md @@ -116,8 +116,8 @@ func readTimeline() async throws { } // Use cursor for pagination - if !timeline.cursor.isEmpty { - let nextPage = try await service.getTimeline(limit: 20, cursor: timeline.cursor) + if let cursor = timeline.cursor { + let nextPage = try await service.getTimeline(limit: 20, cursor: cursor) // Process next page... } } diff --git a/Sources/bskyKit/Documentation.docc/Notifications.md b/Sources/bskyKit/Documentation.docc/Notifications.md index 0d48fa5..a366662 100644 --- a/Sources/bskyKit/Documentation.docc/Notifications.md +++ b/Sources/bskyKit/Documentation.docc/Notifications.md @@ -16,7 +16,7 @@ let service = await BskyService() let response = try await service.listNotifications(limit: 50) for notification in response.notifications { - print("[\(notification.reason.rawValue)] @\(notification.author.handle)") + print("[\(notification.reason)] @\(notification.author.handle)") switch notification.reason { case .like: @@ -36,6 +36,8 @@ for notification in response.notifications { print(" quoted your post") case .starterpackJoined: print(" joined via your starter pack") + case .unknown(let value): + print(" \(value)") } print(" Read: \(notification.isRead)") @@ -84,6 +86,30 @@ print("Notifications marked as read") try await service.updateSeen(at: Date()) ``` +## Notification Preferences and Push + +```swift +// Read preferences +let prefs = try await service.getNotificationPreferences() + +// Legacy preference toggle +try await service.putNotificationPreferences(priority: true) + +// V2 preferences payload +let updated = try await service.putNotificationPreferencesV2([ + "like": ["allow": true], + "mention": ["allow": true] +]) + +// Push registration +try await service.registerPush( + serviceDid: "did:web:push.example.com", + token: "", + platform: "ios", + appID: "com.example.app" +) +``` + ## Filtering Notifications Filter notifications by type: @@ -117,14 +143,15 @@ print("Conversations: \(conversations.count)") The ``NotificationReason`` enum defines all notification types: ```swift -public enum NotificationReason: String, Codable, Sendable { - case like // Someone liked your post - case repost // Someone reposted your post - case follow // Someone followed you - case mention // Someone mentioned you in a post - case reply // Someone replied to your post - case quote // Someone quoted your post - case starterpackJoined = "starterpack-joined" // Someone joined via your starter pack +public enum NotificationReason: Codable, Sendable, Equatable, Hashable { + case like + case repost + case follow + case mention + case reply + case quote + case starterpackJoined + case unknown(String) } ``` @@ -234,7 +261,7 @@ class NotificationManager { ```swift func groupNotifications(_ notifications: [Notification]) -> [String: [Notification]] { Dictionary(grouping: notifications) { notification in - notification.reason.rawValue + String(describing: notification.reason) } } diff --git a/Sources/bskyKit/Documentation.docc/RichTextGuide.md b/Sources/bskyKit/Documentation.docc/RichTextGuide.md index 2ebec9e..566e629 100644 --- a/Sources/bskyKit/Documentation.docc/RichTextGuide.md +++ b/Sources/bskyKit/Documentation.docc/RichTextGuide.md @@ -43,6 +43,8 @@ for facet in richText.facets { print(" Link: \(link.uri)") case .tag(let tag): print(" Tag: #\(tag.tag)") + case .unknown: + break } } } @@ -142,6 +144,7 @@ public enum RichTextFeature: Codable, Sendable { case link(RichTextLink) case mention(RichTextMention) case tag(RichTextTag) + case unknown(String) } ``` diff --git a/Sources/bskyKit/Documentation.docc/SocialActions.md b/Sources/bskyKit/Documentation.docc/SocialActions.md index 7cf309e..eb71ecd 100644 --- a/Sources/bskyKit/Documentation.docc/SocialActions.md +++ b/Sources/bskyKit/Documentation.docc/SocialActions.md @@ -240,6 +240,46 @@ for item in records.records { } ``` +### Put Record + +```swift +let updated = try await repoService.putRecord( + repo: myDID, + collection: "app.bsky.feed.post", + rkey: "abc123", + record: [ + "$type": "app.bsky.feed.post", + "text": "Updated text", + "createdAt": ISO8601DateFormatter().string(from: Date()) + ] +) +print(updated.uri) +``` + +### Apply Batch Writes + +```swift +let response = try await repoService.applyWrites( + repo: myDID, + writes: [ + .create(collection: "app.bsky.feed.post", value: [ + "$type": "app.bsky.feed.post", + "text": "Batch write post", + "createdAt": ISO8601DateFormatter().string(from: Date()) + ]) + ] +) +print(response.results?.count ?? 0) +``` + +### Upload Blob + +```swift +let imageData: Data = ... +let blob = try await repoService.uploadBlob(data: imageData, mimeType: "image/jpeg") +print(blob.blob.ref.link) +``` + ## PostRecord Reference ```swift diff --git a/Sources/bskyKit/Documentation.docc/SocialGraph.md b/Sources/bskyKit/Documentation.docc/SocialGraph.md index 15609e4..754f684 100644 --- a/Sources/bskyKit/Documentation.docc/SocialGraph.md +++ b/Sources/bskyKit/Documentation.docc/SocialGraph.md @@ -138,6 +138,51 @@ for muted in mutes.mutes { > Note: Muting is handled differently from blocks - mute/unmute operations use dedicated endpoints rather than record creation. +### Mute and Unmute an Actor + +```swift +try await service.muteActor("did:plc:user-to-mute") +try await service.unmuteActor("did:plc:user-to-mute") +``` + +### Mute and Unmute a Thread + +```swift +let root = "at://did:plc:author/app.bsky.feed.post/abc123" +try await service.muteThread(root: root) +try await service.unmuteThread(root: root) +``` + +### Mute and Unmute an Actor List + +```swift +let listURI = "at://did:plc:author/app.bsky.graph.list/xyz" +try await service.muteActorList(listURI) +try await service.unmuteActorList(listURI) +``` + +## Relationship Queries + +Use ``BskyService/getRelationships(for:others:)`` to fetch relationship state for multiple actors in one request: + +```swift +let relationships = try await service.getRelationships( + for: "did:plc:mydid", + others: ["did:plc:alice", "did:plc:bob"] +) + +for relationship in relationships.relationships { + switch relationship { + case .relationship(let value): + print("Actor: \(value.did), following: \(value.following != nil)") + case .notFound(let missing): + print("Not found: \(missing.actor)") + case .unknown: + break + } +} +``` + ## Checking Relationships The ``Viewer`` struct on profiles indicates the relationship: diff --git a/Sources/bskyKit/Documentation.docc/TimelineAndFeeds.md b/Sources/bskyKit/Documentation.docc/TimelineAndFeeds.md index 3882c41..29beb38 100644 --- a/Sources/bskyKit/Documentation.docc/TimelineAndFeeds.md +++ b/Sources/bskyKit/Documentation.docc/TimelineAndFeeds.md @@ -41,7 +41,7 @@ var allPosts: [TimelineItem] = [] repeat { let timeline = try await service.getTimeline(limit: 100, cursor: cursor) allPosts.append(contentsOf: timeline.feed) - cursor = timeline.cursor.isEmpty ? nil : timeline.cursor + cursor = timeline.cursor // Limit to 500 posts for this example if allPosts.count >= 500 { break } @@ -157,6 +157,24 @@ for feed in generators.feeds { } ``` +Fetch posts from a specific feed generator: + +```swift +let feedItems = try await service.getFeed( + feed: "at://did:plc:xxx/app.bsky.feed.generator/whats-hot", + limit: 25 +) +``` + +Search posts: + +```swift +let results = try await service.searchPosts(query: "swift", limit: 25) +for post in results.posts { + print(post.record.text ?? "") +} +``` + ## Understanding Timeline Structure ### Timeline @@ -164,7 +182,7 @@ for feed in generators.feeds { ```swift public struct Timeline: Codable, Sendable { public var feed: [TimelineItem] - public var cursor: String + public var cursor: String? } ``` @@ -176,9 +194,12 @@ Each item in the timeline wraps a post and optional reply context: public struct TimelineItem: Codable, Sendable, Identifiable { public let post: Post public let reply: Reply? + public let reason: TimelineReason? + public let feedContext: String? + public let reqId: String? public var id: String { - "\(post.uri ?? "")-\(post.cid ?? "")" + "\(post.uri)-\(post.cid)" } } ``` @@ -187,16 +208,18 @@ public struct TimelineItem: Codable, Sendable, Identifiable { ```swift public struct Post: Codable, Sendable { - public let uri: String? - public let cid: String? + public let uri: String + public let cid: String public let author: Author public let record: Record - public let replyCount: Int - public let repostCount: Int - public let likeCount: Int - public let indexedAt: String - public let viewer: Viewer - public let labels: [String] + public let replyCount: Int? + public let repostCount: Int? + public let likeCount: Int? + public let quoteCount: Int? + public let bookmarkCount: Int? + public let indexedAt: Date + public let viewer: FeedViewer? + public let labels: [AuthorLabels]? public let embed: Embed? } ``` @@ -207,11 +230,11 @@ The post content: ```swift public struct Record: Codable, Sendable { - public let text: String - public let type: String + public let text: String? + public let type: String? public let langs: [String]? public let reply: ReplyDetail? - public let createdAt: String + public let createdAt: Date? public let embed: Embed? public let facets: [Facet]? } @@ -223,8 +246,8 @@ Posts can contain various types of embedded content: ```swift if let embed = post.embed { - switch EmbedType(rawValue: embed.type) { - case .image: + switch embed.type { + case "app.bsky.embed.images#view": // Image embed if let images = embed.images { for image in images { @@ -232,20 +255,20 @@ if let embed = post.embed { } } - case .external: + case "app.bsky.embed.external#view": // Link preview if let external = embed.external { print("Link: \(external.title)") print("URL: \(external.uri ?? "")") } - case .record: + case "app.bsky.embed.record#view": // Quote post if let record = embed.record { print("Quote: \(record.value?.text ?? "")") } - case .recordWithMedia: + case "app.bsky.embed.recordWithMedia#view": // Quote post with images print("Quote with media") diff --git a/Sources/bskyKit/Documentation.docc/WorkingWithProfiles.md b/Sources/bskyKit/Documentation.docc/WorkingWithProfiles.md index b5ea71d..2df3119 100644 --- a/Sources/bskyKit/Documentation.docc/WorkingWithProfiles.md +++ b/Sources/bskyKit/Documentation.docc/WorkingWithProfiles.md @@ -91,6 +91,17 @@ for actor in typeahead.actors { > Note: Typeahead search is optimized for speed and returns fewer fields than full search. +### Suggestions + +Use ``BskyService/getSuggestions(limit:cursor:)`` for recommendation-style actor suggestions: + +```swift +let suggestions = try await service.getSuggestions(limit: 20) +for actor in suggestions.actors { + print("@\(actor.handle)") +} +``` + ## Understanding Viewer State The ``Viewer`` struct indicates the relationship between the authenticated user and a profile: @@ -127,6 +138,11 @@ let preferences = try await service.getPreferences() for savedFeed in preferences.saved { print("Saved: \(savedFeed)") } + +// Update preferences payload when needed +try await service.putPreferences([ + "adultContentEnabled": false +]) ``` ## Profile Model Reference diff --git a/Sources/bskyKit/Documentation.docc/bskyKit.md b/Sources/bskyKit/Documentation.docc/bskyKit.md index c692668..bd3a25f 100644 --- a/Sources/bskyKit/Documentation.docc/bskyKit.md +++ b/Sources/bskyKit/Documentation.docc/bskyKit.md @@ -4,7 +4,7 @@ A Swift SDK for interacting with Bluesky social network APIs. ## Overview -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. +bskyKit provides a Swift-native interface to Bluesky AT Protocol APIs. Built on top of CoreATProtocol, it supports the full `app.bsky` query/procedure surface, including stable typed models and `JSONValue` for endpoints with rapidly evolving schemas. ### Key Features @@ -14,6 +14,7 @@ bskyKit provides a type-safe, Swift-native interface to the Bluesky AT Protocol - **Rich Text**: Auto-detect mentions, links, and hashtags with proper byte indexing - **Write Operations**: Create posts, likes, reposts, follows, and blocks - **Notifications**: List notifications and manage read state +- **Complete app.bsky Coverage**: Actor, feed, graph, notification, bookmark, age-assurance, unspecced, and video endpoints ### Quick Start @@ -110,6 +111,7 @@ bskyKit is organized into several key components: - ``Feed`` - ``Feeds`` +- ``JSONValue`` - ``Creator`` - ``Preferences`` - ``Blocks`` -- 2.51.2