this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

stability

+1628 -317
+1 -10
Package.resolved
··· 1 1 { 2 - "originHash" : "588bff50c2acc1e7fc8a48e4cd7e69605871ec12cb138ce96710e0b88cebb635", 2 + "originHash" : "cbf9e754c518e5eb56690c6dd9d096349450adf8254fc91d657d65f243cfb57f", 3 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 4 { 14 5 "identity" : "jwt-kit", 15 6 "kind" : "remoteSourceControl",
+2 -1
Package.swift
··· 18 18 ), 19 19 ], 20 20 dependencies: [ 21 - .package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"), 21 + .package(path: "../CoreATProtocol"), 22 + // .package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"), 22 23 ], 23 24 targets: [ 24 25 .target(
+157 -7
Sources/bskyKit/BskyAPI.swift
··· 8 8 import Foundation 9 9 import CoreATProtocol 10 10 11 + struct SendableAny: @unchecked Sendable { 12 + let value: Any 13 + } 14 + 11 15 enum BskyAPI { 12 16 // Actor endpoints 13 17 case getPreferences 14 18 case getProfile(did: String) 15 19 case getProfiles(dids: [String]) 20 + case getSuggestions(limit: Int, cursor: String?) 16 21 case searchActors(query: String, limit: Int) 17 22 case searchActorsTypeahead(query: String, limit: Int) 18 23 19 24 // Feed endpoints 20 25 case getFeed(feed: String, limit: Int, cursor: String?) 26 + case getActorFeeds(actor: String, limit: Int, cursor: String?) 27 + case getFeedGenerator(feed: String) 21 28 case getFeedGenerators(feeds: [String]) 29 + case getListFeed(list: String, limit: Int, cursor: String?) 30 + case getQuotes(uri: String, cid: String?, limit: Int, cursor: String?) 31 + case getSuggestedFeeds(limit: Int, cursor: String?) 22 32 case getTimeline(limit: Int, cursor: String?) 23 33 case getAuthorFeed(did: String, limit: Int, cursor: String?, filter: String?) 24 34 case getPostThread(uri: String, depth: Int) 25 35 case getPosts(uris: [String]) 36 + case searchPosts( 37 + query: String, 38 + sort: String?, 39 + since: String?, 40 + until: String?, 41 + mentions: String?, 42 + author: String?, 43 + lang: String?, 44 + domain: String?, 45 + url: String?, 46 + tags: [String]?, 47 + limit: Int, 48 + cursor: String? 49 + ) 26 50 case getActorLikes(did: String, limit: Int, cursor: String?) 27 51 case getLikes(uri: String, limit: Int, cursor: String?) 28 52 case getRepostedBy(uri: String, limit: Int, cursor: String?) ··· 32 56 case getFollowers(did: String, limit: Int, cursor: String?) 33 57 case getBlocks(limit: Int, cursor: String?) 34 58 case getMutes(limit: Int, cursor: String?) 59 + case getRelationships(actor: String, others: [String]) 60 + case muteActor(actor: String) 61 + case unmuteActor(actor: String) 62 + case muteThread(root: String) 63 + case unmuteThread(root: String) 64 + case muteActorList(list: String) 65 + case unmuteActorList(list: String) 35 66 36 67 // Notification endpoints 37 68 case listNotifications(limit: Int, cursor: String?) 38 69 case getUnreadCount 39 70 case updateSeen(seenAt: Date) 71 + 72 + // Generic endpoints for newer/less-common lexicons. 73 + case xrpcQuery(id: String, parameters: [String: SendableAny]) 74 + case xrpcProcedure(id: String, body: [String: SendableAny]?) 75 + case xrpcDataProcedure(id: String, data: Data, contentType: String, accept: String?) 40 76 } 41 77 42 78 extension BskyAPI: EndpointType { 43 79 public var baseURL: URL { 44 80 get async { 45 - guard let host = await APEnvironment.current.host else { fatalError("Host not set.") } 46 - guard let url = URL(string: host) else { fatalError("BskyAPI baseURL not configured.") } 81 + guard let host = await APEnvironment.current.host, 82 + let url = URL(string: host) else { 83 + return URL(string: "https://invalid.invalid")! 84 + } 47 85 return url 48 86 } 49 87 } ··· 54 92 case .getPreferences: "/xrpc/app.bsky.actor.getPreferences" 55 93 case .getProfile: "/xrpc/app.bsky.actor.getProfile" 56 94 case .getProfiles: "/xrpc/app.bsky.actor.getProfiles" 95 + case .getSuggestions: "/xrpc/app.bsky.actor.getSuggestions" 57 96 case .searchActors: "/xrpc/app.bsky.actor.searchActors" 58 97 case .searchActorsTypeahead: "/xrpc/app.bsky.actor.searchActorsTypeahead" 59 98 // Feed 60 99 case .getFeed: "/xrpc/app.bsky.feed.getFeed" 100 + case .getActorFeeds: "/xrpc/app.bsky.feed.getActorFeeds" 101 + case .getFeedGenerator: "/xrpc/app.bsky.feed.getFeedGenerator" 61 102 case .getFeedGenerators: "/xrpc/app.bsky.feed.getFeedGenerators" 103 + case .getListFeed: "/xrpc/app.bsky.feed.getListFeed" 104 + case .getQuotes: "/xrpc/app.bsky.feed.getQuotes" 105 + case .getSuggestedFeeds: "/xrpc/app.bsky.feed.getSuggestedFeeds" 62 106 case .getTimeline: "/xrpc/app.bsky.feed.getTimeline" 63 107 case .getAuthorFeed: "/xrpc/app.bsky.feed.getAuthorFeed" 64 108 case .getPostThread: "/xrpc/app.bsky.feed.getPostThread" 65 109 case .getPosts: "/xrpc/app.bsky.feed.getPosts" 110 + case .searchPosts: "/xrpc/app.bsky.feed.searchPosts" 66 111 case .getActorLikes: "/xrpc/app.bsky.feed.getActorLikes" 67 112 case .getLikes: "/xrpc/app.bsky.feed.getLikes" 68 113 case .getRepostedBy: "/xrpc/app.bsky.feed.getRepostedBy" ··· 71 116 case .getFollowers: "/xrpc/app.bsky.graph.getFollowers" 72 117 case .getBlocks: "/xrpc/app.bsky.graph.getBlocks" 73 118 case .getMutes: "/xrpc/app.bsky.graph.getMutes" 119 + case .getRelationships: "/xrpc/app.bsky.graph.getRelationships" 120 + case .muteActor: "/xrpc/app.bsky.graph.muteActor" 121 + case .unmuteActor: "/xrpc/app.bsky.graph.unmuteActor" 122 + case .muteThread: "/xrpc/app.bsky.graph.muteThread" 123 + case .unmuteThread: "/xrpc/app.bsky.graph.unmuteThread" 124 + case .muteActorList: "/xrpc/app.bsky.graph.muteActorList" 125 + case .unmuteActorList: "/xrpc/app.bsky.graph.unmuteActorList" 74 126 // Notifications 75 127 case .listNotifications: "/xrpc/app.bsky.notification.listNotifications" 76 128 case .getUnreadCount: "/xrpc/app.bsky.notification.getUnreadCount" 77 129 case .updateSeen: "/xrpc/app.bsky.notification.updateSeen" 130 + case .xrpcQuery(let id, _), .xrpcProcedure(let id, _), .xrpcDataProcedure(let id, _, _, _): 131 + "/xrpc/\(id)" 78 132 } 79 133 } 80 134 81 135 var httpMethod: HTTPMethod { 82 136 switch self { 83 - case .getPreferences, .getProfile, .getProfiles, .searchActors, .searchActorsTypeahead, 84 - .getFeed, .getFeedGenerators, .getTimeline, .getAuthorFeed, .getPostThread, .getPosts, .getActorLikes, .getLikes, .getRepostedBy, 85 - .getFollows, .getFollowers, .getBlocks, .getMutes, 137 + case .getPreferences, .getProfile, .getProfiles, .getSuggestions, .searchActors, .searchActorsTypeahead, 138 + .getFeed, .getActorFeeds, .getFeedGenerator, .getFeedGenerators, .getListFeed, .getQuotes, .getSuggestedFeeds, 139 + .getTimeline, .getAuthorFeed, .getPostThread, .getPosts, .searchPosts, .getActorLikes, .getLikes, .getRepostedBy, 140 + .getFollows, .getFollowers, .getBlocks, .getMutes, .getRelationships, 86 141 .listNotifications, .getUnreadCount: 87 142 return .get 88 - case .updateSeen: 143 + case .updateSeen, .muteActor, .unmuteActor, .muteThread, .unmuteThread, .muteActorList, .unmuteActorList, 144 + .xrpcProcedure, .xrpcDataProcedure: 89 145 return .post 146 + case .xrpcQuery: 147 + return .get 90 148 } 91 149 } 92 150 ··· 102 160 case .getProfiles(let dids): 103 161 return .requestParameters(encoding: .urlEncoding(parameters: ["actors": dids])) 104 162 163 + case .getSuggestions(let limit, let cursor): 164 + var params: Parameters = ["limit": limit] 165 + if let cursor { params["cursor"] = cursor } 166 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 167 + 105 168 case .searchActors(let query, let limit): 106 169 return .requestParameters(encoding: .urlEncoding(parameters: [ 107 170 "q": query, ··· 120 183 if let cursor { params["cursor"] = cursor } 121 184 return .requestParameters(encoding: .urlEncoding(parameters: params)) 122 185 186 + case .getActorFeeds(let actor, let limit, let cursor): 187 + var params: Parameters = ["actor": actor, "limit": limit] 188 + if let cursor { params["cursor"] = cursor } 189 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 190 + 191 + case .getFeedGenerator(let feed): 192 + return .requestParameters(encoding: .urlEncoding(parameters: ["feed": feed])) 193 + 123 194 case .getFeedGenerators(let feeds): 124 195 return .requestParameters(encoding: .urlEncoding(parameters: ["feeds": feeds])) 125 196 197 + case .getListFeed(let list, let limit, let cursor): 198 + var params: Parameters = ["list": list, "limit": limit] 199 + if let cursor { params["cursor"] = cursor } 200 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 201 + 202 + case .getQuotes(let uri, let cid, let limit, let cursor): 203 + var params: Parameters = ["uri": uri, "limit": limit] 204 + if let cid { params["cid"] = cid } 205 + if let cursor { params["cursor"] = cursor } 206 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 207 + 208 + case .getSuggestedFeeds(let limit, let cursor): 209 + var params: Parameters = ["limit": limit] 210 + if let cursor { params["cursor"] = cursor } 211 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 212 + 126 213 case .getTimeline(let limit, let cursor): 127 214 var params: Parameters = ["limit": limit] 128 215 if let cursor { params["cursor"] = cursor } ··· 143 230 case .getPosts(let uris): 144 231 return .requestParameters(encoding: .urlEncoding(parameters: ["uris": uris])) 145 232 233 + case .searchPosts( 234 + let query, 235 + let sort, 236 + let since, 237 + let until, 238 + let mentions, 239 + let author, 240 + let lang, 241 + let domain, 242 + let url, 243 + let tags, 244 + let limit, 245 + let cursor 246 + ): 247 + var params: Parameters = ["q": query, "limit": limit] 248 + if let sort { params["sort"] = sort } 249 + if let since { params["since"] = since } 250 + if let until { params["until"] = until } 251 + if let mentions { params["mentions"] = mentions } 252 + if let author { params["author"] = author } 253 + if let lang { params["lang"] = lang } 254 + if let domain { params["domain"] = domain } 255 + if let url { params["url"] = url } 256 + if let tags, !tags.isEmpty { params["tag"] = tags } 257 + if let cursor { params["cursor"] = cursor } 258 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 259 + 146 260 case .getActorLikes(let did, let limit, let cursor): 147 261 var params: Parameters = ["actor": did, "limit": limit] 148 262 if let cursor { params["cursor"] = cursor } ··· 179 293 if let cursor { params["cursor"] = cursor } 180 294 return .requestParameters(encoding: .urlEncoding(parameters: params)) 181 295 296 + case .getRelationships(let actor, let others): 297 + return .requestParameters(encoding: .urlEncoding(parameters: [ 298 + "actor": actor, 299 + "others": others 300 + ])) 301 + 302 + case .muteActor(let actor), .unmuteActor(let actor): 303 + return .requestParameters(encoding: .jsonEncoding(parameters: ["actor": actor])) 304 + 305 + case .muteThread(let root), .unmuteThread(let root): 306 + return .requestParameters(encoding: .jsonEncoding(parameters: ["root": root])) 307 + 308 + case .muteActorList(let list), .unmuteActorList(let list): 309 + return .requestParameters(encoding: .jsonEncoding(parameters: ["list": list])) 310 + 182 311 // Notification endpoints 183 312 case .listNotifications(let limit, let cursor): 184 313 var params: Parameters = ["limit": limit] ··· 191 320 return .requestParameters(encoding: .jsonEncoding(parameters: [ 192 321 "seenAt": formatter.string(from: seenAt) 193 322 ])) 323 + 324 + case .xrpcQuery(_, let parameters): 325 + let unboxed = parameters.mapValues(\.value) 326 + if parameters.isEmpty { 327 + return .request 328 + } 329 + return .requestParameters(encoding: .urlEncoding(parameters: unboxed)) 330 + 331 + case .xrpcProcedure(_, let body): 332 + guard let body else { return .request } 333 + return .requestParameters(encoding: .jsonEncoding(parameters: body.mapValues(\.value))) 334 + 335 + case .xrpcDataProcedure(_, let data, _, _): 336 + return .requestParameters(encoding: .jsonDataEncoding(data: data)) 194 337 } 195 338 } 196 339 197 340 var headers: HTTPHeaders? { 198 - nil 341 + switch self { 342 + case .xrpcDataProcedure(_, _, let contentType, let accept): 343 + var headers: HTTPHeaders = ["Content-Type": contentType] 344 + if let accept { headers["Accept"] = accept } 345 + return headers 346 + default: 347 + return nil 348 + } 199 349 } 200 350 }
+596 -22
Sources/bskyKit/BskyService.swift
··· 72 72 /// `setup(hostURL:accessJWT:refreshJWT:)`. 73 73 public init() {} 74 74 75 + private func execute<T: Decodable>(_ endpoint: BskyAPI) async throws -> T { 76 + try ensureHostConfigured() 77 + return try await router.execute(endpoint) 78 + } 79 + 80 + private func executeQuery<T: Decodable>( 81 + _ id: String, 82 + parameters: Parameters = [:] 83 + ) async throws -> T { 84 + try await execute(.xrpcQuery(id: id, parameters: box(parameters))) 85 + } 86 + 87 + private func executeProcedure<T: Decodable>( 88 + _ id: String, 89 + body: Parameters? = nil 90 + ) async throws -> T { 91 + try await execute(.xrpcProcedure(id: id, body: body.map(box))) 92 + } 93 + 94 + private func executeDataProcedure<T: Decodable>( 95 + _ id: String, 96 + data: Data, 97 + contentType: String, 98 + accept: String? = "application/json" 99 + ) async throws -> T { 100 + try await execute(.xrpcDataProcedure( 101 + id: id, 102 + data: data, 103 + contentType: contentType, 104 + accept: accept 105 + )) 106 + } 107 + 108 + private func box(_ parameters: Parameters) -> [String: SendableAny] { 109 + parameters.mapValues { SendableAny(value: $0) } 110 + } 111 + 75 112 // MARK: - Actor 76 113 77 114 /// Fetches the authenticated user's preferences. 78 115 /// - Returns: The user's saved preferences including pinned feeds. 79 116 /// - Throws: An error if the request fails or the user is not authenticated. 80 117 public func getPreferences() async throws -> Preferences { 81 - try await router.execute(.getPreferences) 118 + try await execute(.getPreferences) 82 119 } 83 120 84 121 /// Fetches a user profile by handle or DID. ··· 86 123 /// - Returns: The user's profile. 87 124 /// - Throws: An error if the profile is not found or the request fails. 88 125 public func getProfile(for did: String) async throws -> Profile { 89 - try await router.execute(.getProfile(did: did)) 126 + try await execute(.getProfile(did: did)) 90 127 } 91 128 92 129 /// Fetches multiple user profiles in a single request. ··· 94 131 /// - Returns: The profiles for the requested users. 95 132 /// - Throws: An error if the request fails. 96 133 public func getProfiles(for dids: [String]) async throws -> Profiles { 97 - try await router.execute(.getProfiles(dids: dids)) 134 + try await execute(.getProfiles(dids: dids)) 135 + } 136 + 137 + /// Fetches actor suggestions for the authenticated user. 138 + public func getSuggestions(limit: Int = 25, cursor: String? = nil) async throws -> ActorSuggestionsResponse { 139 + try await execute(.getSuggestions(limit: limit, cursor: cursor)) 98 140 } 99 141 100 142 /// Searches for users matching a query. ··· 104 146 /// - Returns: Matching user profiles with optional cursor for pagination. 105 147 /// - Throws: An error if the request fails. 106 148 public func searchActors(query: String, limit: Int = 25) async throws -> SearchActorsResult { 107 - try await router.execute(.searchActors(query: query, limit: limit)) 149 + try await execute(.searchActors(query: query, limit: limit)) 108 150 } 109 151 110 152 /// Fast search for autocomplete functionality. ··· 114 156 /// - Returns: Matching profiles optimized for autocomplete. 115 157 /// - Throws: An error if the request fails. 116 158 public func searchActorsTypeahead(query: String, limit: Int = 10) async throws -> SearchActorsTypeaheadResult { 117 - try await router.execute(.searchActorsTypeahead(query: query, limit: limit)) 159 + try await execute(.searchActorsTypeahead(query: query, limit: limit)) 118 160 } 119 161 120 162 // MARK: - Feed ··· 127 169 /// - Returns: Feed posts with cursor for pagination. 128 170 /// - Throws: An error if the feed is not found or the request fails. 129 171 public func getFeed(feed: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed { 130 - try await router.execute(.getFeed(feed: feed, limit: limit, cursor: cursor)) 172 + try await execute(.getFeed(feed: feed, limit: limit, cursor: cursor)) 173 + } 174 + 175 + /// Fetches feed generators by actor. 176 + public func getActorFeeds(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> FeedPageResponse { 177 + try await execute(.getActorFeeds(actor: actor, limit: limit, cursor: cursor)) 178 + } 179 + 180 + /// Fetches a single feed generator by AT-URI. 181 + public func getFeedGenerator(feed: String) async throws -> FeedGeneratorResponse { 182 + try await execute(.getFeedGenerator(feed: feed)) 131 183 } 132 184 133 185 /// Fetches information about custom feed generators. ··· 135 187 /// - Returns: Details about the requested feed generators. 136 188 /// - Throws: An error if the request fails. 137 189 public func getFeedGenerators(for feeds: [String]) async throws -> Feeds { 138 - try await router.execute(.getFeedGenerators(feeds: feeds)) 190 + try await execute(.getFeedGenerators(feeds: feeds)) 191 + } 192 + 193 + /// Fetches posts from a list feed. 194 + public func getListFeed(list: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed { 195 + try await execute(.getListFeed(list: list, limit: limit, cursor: cursor)) 196 + } 197 + 198 + /// Fetches quotes for a post. 199 + public func getQuotes(uri: String, cid: String? = nil, limit: Int = 50, cursor: String? = nil) async throws -> QuotesResponse { 200 + try await execute(.getQuotes(uri: uri, cid: cid, limit: limit, cursor: cursor)) 201 + } 202 + 203 + /// Fetches suggested feed generators. 204 + public func getSuggestedFeeds(limit: Int = 50, cursor: String? = nil) async throws -> FeedPageResponse { 205 + try await execute(.getSuggestedFeeds(limit: limit, cursor: cursor)) 139 206 } 140 207 141 208 /// Fetches the authenticated user's home timeline. ··· 145 212 /// - Returns: Timeline posts with cursor for pagination. 146 213 /// - Throws: An error if the user is not authenticated or the request fails. 147 214 public func getTimeline(limit: Int = 50, cursor: String? = nil) async throws -> Timeline { 148 - try await router.execute(.getTimeline(limit: limit, cursor: cursor)) 215 + try await execute(.getTimeline(limit: limit, cursor: cursor)) 149 216 } 150 217 151 218 /// Fetches posts from a specific user's feed. ··· 157 224 /// - Returns: The user's posts with cursor for pagination. 158 225 /// - Throws: An error if the request fails. 159 226 public func getAuthorFeed(for did: String, limit: Int = 50, cursor: String? = nil, filter: String? = nil) async throws -> AuthorFeed { 160 - try await router.execute(.getAuthorFeed(did: did, limit: limit, cursor: cursor, filter: filter)) 227 + try await execute(.getAuthorFeed(did: did, limit: limit, cursor: cursor, filter: filter)) 161 228 } 162 229 163 230 /// Fetches a post and its reply thread. ··· 167 234 /// - Returns: The post thread with nested replies. 168 235 /// - Throws: An error if the post is not found or the request fails. 169 236 public func getPostThread(uri: String, depth: Int = 6) async throws -> PostThreadResponse { 170 - try await router.execute(.getPostThread(uri: uri, depth: depth)) 237 + try await execute(.getPostThread(uri: uri, depth: depth)) 171 238 } 172 239 173 240 /// Fetches multiple posts by URI in a single request. ··· 175 242 /// - Returns: The requested posts. 176 243 /// - Throws: An error if the request fails. 177 244 public func getPosts(uris: [String]) async throws -> Posts { 178 - try await router.execute(.getPosts(uris: uris)) 245 + try await execute(.getPosts(uris: uris)) 246 + } 247 + 248 + /// Searches posts. 249 + public func searchPosts( 250 + query: String, 251 + sort: String? = nil, 252 + since: String? = nil, 253 + until: String? = nil, 254 + mentions: String? = nil, 255 + author: String? = nil, 256 + lang: String? = nil, 257 + domain: String? = nil, 258 + url: String? = nil, 259 + tags: [String]? = nil, 260 + limit: Int = 25, 261 + cursor: String? = nil 262 + ) async throws -> SearchPostsResponse { 263 + try await execute(.searchPosts( 264 + query: query, 265 + sort: sort, 266 + since: since, 267 + until: until, 268 + mentions: mentions, 269 + author: author, 270 + lang: lang, 271 + domain: domain, 272 + url: url, 273 + tags: tags, 274 + limit: limit, 275 + cursor: cursor 276 + )) 179 277 } 180 278 181 279 /// Fetches posts liked by a specific user. ··· 186 284 /// - Returns: The user's liked posts with cursor for pagination. 187 285 /// - Throws: An error if the request fails. 188 286 public func getActorLikes(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed { 189 - try await router.execute(.getActorLikes(did: did, limit: limit, cursor: cursor)) 287 + try await execute(.getActorLikes(did: did, limit: limit, cursor: cursor)) 190 288 } 191 289 192 290 /// Fetches users who liked a specific post. ··· 197 295 /// - Returns: Users who liked the post with cursor for pagination. 198 296 /// - Throws: An error if the request fails. 199 297 public func getLikes(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> Likes { 200 - try await router.execute(.getLikes(uri: uri, limit: limit, cursor: cursor)) 298 + try await execute(.getLikes(uri: uri, limit: limit, cursor: cursor)) 201 299 } 202 300 203 301 /// Fetches users who reposted a specific post. ··· 208 306 /// - Returns: Users who reposted with cursor for pagination. 209 307 /// - Throws: An error if the request fails. 210 308 public func getRepostedBy(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> RepostedBy { 211 - try await router.execute(.getRepostedBy(uri: uri, limit: limit, cursor: cursor)) 309 + try await execute(.getRepostedBy(uri: uri, limit: limit, cursor: cursor)) 212 310 } 213 311 214 312 // MARK: - Graph ··· 221 319 /// - Returns: Users being followed with cursor for pagination. 222 320 /// - Throws: An error if the request fails. 223 321 public func getFollows(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> Follows { 224 - try await router.execute(.getFollows(did: did, limit: limit, cursor: cursor)) 322 + try await execute(.getFollows(did: did, limit: limit, cursor: cursor)) 225 323 } 226 324 227 325 /// Fetches the list of users following a specific user. ··· 232 330 /// - Returns: Followers with cursor for pagination. 233 331 /// - Throws: An error if the request fails. 234 332 public func getFollowers(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> Followers { 235 - try await router.execute(.getFollowers(did: did, limit: limit, cursor: cursor)) 333 + try await execute(.getFollowers(did: did, limit: limit, cursor: cursor)) 236 334 } 237 335 238 336 /// Fetches the authenticated user's blocked accounts. ··· 242 340 /// - Returns: Blocked profiles with cursor for pagination. 243 341 /// - Throws: An error if not authenticated or the request fails. 244 342 public func getBlocks(limit: Int = 50, cursor: String? = nil) async throws -> Blocks { 245 - try await router.execute(.getBlocks(limit: limit, cursor: cursor)) 343 + try await execute(.getBlocks(limit: limit, cursor: cursor)) 246 344 } 247 345 248 346 /// Fetches the authenticated user's muted accounts. ··· 252 350 /// - Returns: Muted profiles with cursor for pagination. 253 351 /// - Throws: An error if not authenticated or the request fails. 254 352 public func getMutes(limit: Int = 50, cursor: String? = nil) async throws -> Mutes { 255 - try await router.execute(.getMutes(limit: limit, cursor: cursor)) 353 + try await execute(.getMutes(limit: limit, cursor: cursor)) 354 + } 355 + 356 + /// Fetches relationship state between an actor and other actors. 357 + public func getRelationships(for actor: String, others: [String]) async throws -> RelationshipsResponse { 358 + try await execute(.getRelationships(actor: actor, others: others)) 359 + } 360 + 361 + /// Mutes an actor. 362 + public func muteActor(_ actor: String) async throws { 363 + let _: EmptyResponse = try await execute(.muteActor(actor: actor)) 364 + } 365 + 366 + /// Unmutes an actor. 367 + public func unmuteActor(_ actor: String) async throws { 368 + let _: EmptyResponse = try await execute(.unmuteActor(actor: actor)) 369 + } 370 + 371 + /// Mutes a thread by root URI. 372 + public func muteThread(root: String) async throws { 373 + let _: EmptyResponse = try await execute(.muteThread(root: root)) 374 + } 375 + 376 + /// Unmutes a thread by root URI. 377 + public func unmuteThread(root: String) async throws { 378 + let _: EmptyResponse = try await execute(.unmuteThread(root: root)) 379 + } 380 + 381 + /// Mutes an actor list by URI. 382 + public func muteActorList(_ list: String) async throws { 383 + let _: EmptyResponse = try await execute(.muteActorList(list: list)) 384 + } 385 + 386 + /// Unmutes an actor list by URI. 387 + public func unmuteActorList(_ list: String) async throws { 388 + let _: EmptyResponse = try await execute(.unmuteActorList(list: list)) 256 389 } 257 390 258 391 // MARK: - Notifications ··· 264 397 /// - Returns: Notifications with cursor for pagination. 265 398 /// - Throws: An error if not authenticated or the request fails. 266 399 public func listNotifications(limit: Int = 50, cursor: String? = nil) async throws -> NotificationsResponse { 267 - try await router.execute(.listNotifications(limit: limit, cursor: cursor)) 400 + try await execute(.listNotifications(limit: limit, cursor: cursor)) 268 401 } 269 402 270 403 /// Fetches the count of unread notifications. 271 404 /// - Returns: The number of unread notifications. 272 405 /// - Throws: An error if not authenticated or the request fails. 273 406 public func getUnreadCount() async throws -> UnreadCount { 274 - try await router.execute(.getUnreadCount) 407 + try await execute(.getUnreadCount) 275 408 } 276 409 277 410 /// Marks notifications as seen up to the specified time. 278 411 /// - Parameter date: The timestamp to mark as seen (default: now). 279 412 /// - Throws: An error if not authenticated or the request fails. 280 413 public func updateSeen(at date: Date = Date()) async throws { 281 - let _: EmptyResponse = try await router.execute(.updateSeen(seenAt: date)) 414 + let _: EmptyResponse = try await execute(.updateSeen(seenAt: date)) 415 + } 416 + 417 + // MARK: - Priority 2: Account, Bookmark, and Graph Collections 418 + 419 + /// Updates actor preferences payload. 420 + public func putPreferences(_ preferences: [String: Any]) async throws { 421 + let _: EmptyResponse = try await executeProcedure( 422 + "app.bsky.actor.putPreferences", 423 + body: ["preferences": preferences] 424 + ) 425 + } 426 + 427 + /// Creates a bookmark for a post. 428 + public func createBookmark(uri: String, cid: String) async throws { 429 + let _: EmptyResponse = try await executeProcedure( 430 + "app.bsky.bookmark.createBookmark", 431 + body: ["uri": uri, "cid": cid] 432 + ) 433 + } 434 + 435 + /// Deletes a bookmark by post URI. 436 + public func deleteBookmark(uri: String) async throws { 437 + let _: EmptyResponse = try await executeProcedure( 438 + "app.bsky.bookmark.deleteBookmark", 439 + body: ["uri": uri] 440 + ) 441 + } 442 + 443 + /// Lists bookmarks for the authenticated actor. 444 + public func getBookmarks(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { 445 + var params: Parameters = ["limit": limit] 446 + if let cursor { params["cursor"] = cursor } 447 + return try await executeQuery("app.bsky.bookmark.getBookmarks", parameters: params) 282 448 } 283 - } 284 449 450 + /// Describes the current feed generator account and feeds. 451 + public func describeFeedGenerator() async throws -> JSONValue { 452 + try await executeQuery("app.bsky.feed.describeFeedGenerator") 453 + } 454 + 455 + /// Fetches raw feed skeleton response for a feed generator. 456 + public func getFeedSkeleton(feed: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { 457 + var params: Parameters = ["feed": feed, "limit": limit] 458 + if let cursor { params["cursor"] = cursor } 459 + return try await executeQuery("app.bsky.feed.getFeedSkeleton", parameters: params) 460 + } 461 + 462 + /// Sends interaction signals to ranking services. 463 + public func sendInteractions(_ interactions: [[String: Any]]) async throws { 464 + let _: EmptyResponse = try await executeProcedure( 465 + "app.bsky.feed.sendInteractions", 466 + body: ["interactions": interactions] 467 + ) 468 + } 469 + 470 + /// Fetches starter packs authored by an actor. 471 + public func getActorStarterPacks(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { 472 + var params: Parameters = ["actor": actor, "limit": limit] 473 + if let cursor { params["cursor"] = cursor } 474 + return try await executeQuery("app.bsky.graph.getActorStarterPacks", parameters: params) 475 + } 476 + 477 + /// Fetches known followers for an actor. 478 + public func getKnownFollowers(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> Followers { 479 + var params: Parameters = ["actor": actor, "limit": limit] 480 + if let cursor { params["cursor"] = cursor } 481 + return try await executeQuery("app.bsky.graph.getKnownFollowers", parameters: params) 482 + } 483 + 484 + /// Fetches a list and its membership. 485 + public func getList(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { 486 + var params: Parameters = ["list": uri, "limit": limit] 487 + if let cursor { params["cursor"] = cursor } 488 + return try await executeQuery("app.bsky.graph.getList", parameters: params) 489 + } 490 + 491 + /// Fetches lists blocked by the authenticated actor. 492 + public func getListBlocks(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { 493 + var params: Parameters = ["limit": limit] 494 + if let cursor { params["cursor"] = cursor } 495 + return try await executeQuery("app.bsky.graph.getListBlocks", parameters: params) 496 + } 497 + 498 + /// Fetches lists muted by the authenticated actor. 499 + public func getListMutes(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { 500 + var params: Parameters = ["limit": limit] 501 + if let cursor { params["cursor"] = cursor } 502 + return try await executeQuery("app.bsky.graph.getListMutes", parameters: params) 503 + } 504 + 505 + /// Fetches lists created by an actor. 506 + public func getLists( 507 + for actor: String, 508 + limit: Int = 50, 509 + cursor: String? = nil, 510 + purposes: [String]? = nil 511 + ) async throws -> JSONValue { 512 + var params: Parameters = ["actor": actor, "limit": limit] 513 + if let cursor { params["cursor"] = cursor } 514 + if let purposes, !purposes.isEmpty { params["purposes"] = purposes } 515 + return try await executeQuery("app.bsky.graph.getLists", parameters: params) 516 + } 517 + 518 + /// Fetches lists with membership state for an actor. 519 + public func getListsWithMembership( 520 + for actor: String, 521 + limit: Int = 50, 522 + cursor: String? = nil, 523 + purposes: [String]? = nil 524 + ) async throws -> JSONValue { 525 + var params: Parameters = ["actor": actor, "limit": limit] 526 + if let cursor { params["cursor"] = cursor } 527 + if let purposes, !purposes.isEmpty { params["purposes"] = purposes } 528 + return try await executeQuery("app.bsky.graph.getListsWithMembership", parameters: params) 529 + } 530 + 531 + /// Fetches a single starter pack. 532 + public func getStarterPack(uri: String) async throws -> JSONValue { 533 + try await executeQuery("app.bsky.graph.getStarterPack", parameters: ["starterPack": uri]) 534 + } 535 + 536 + /// Fetches multiple starter packs by URI. 537 + public func getStarterPacks(uris: [String]) async throws -> JSONValue { 538 + try await executeQuery("app.bsky.graph.getStarterPacks", parameters: ["uris": uris]) 539 + } 540 + 541 + /// Fetches starter packs with membership for an actor. 542 + public func getStarterPacksWithMembership(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { 543 + var params: Parameters = ["actor": actor, "limit": limit] 544 + if let cursor { params["cursor"] = cursor } 545 + return try await executeQuery("app.bsky.graph.getStarterPacksWithMembership", parameters: params) 546 + } 547 + 548 + /// Fetches suggested follows for an actor. 549 + public func getSuggestedFollowsByActor(for actor: String) async throws -> JSONValue { 550 + try await executeQuery("app.bsky.graph.getSuggestedFollowsByActor", parameters: ["actor": actor]) 551 + } 552 + 553 + /// Searches starter packs. 554 + public func searchStarterPacks(query: String, limit: Int = 25, cursor: String? = nil) async throws -> JSONValue { 555 + var params: Parameters = ["q": query, "limit": limit] 556 + if let cursor { params["cursor"] = cursor } 557 + return try await executeQuery("app.bsky.graph.searchStarterPacks", parameters: params) 558 + } 559 + 560 + /// Fetches labeler services by DID. 561 + public func getLabelerServices(dids: [String], detailed: Bool? = nil) async throws -> JSONValue { 562 + var params: Parameters = ["dids": dids] 563 + if let detailed { params["detailed"] = detailed } 564 + return try await executeQuery("app.bsky.labeler.getServices", parameters: params) 565 + } 566 + 567 + // MARK: - Priority 3: Notification Preferences and Push 568 + 569 + /// Fetches notification preferences. 570 + public func getNotificationPreferences() async throws -> JSONValue { 571 + try await executeQuery("app.bsky.notification.getPreferences") 572 + } 573 + 574 + /// Lists activity subscriptions. 575 + public func listActivitySubscriptions(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { 576 + var params: Parameters = ["limit": limit] 577 + if let cursor { params["cursor"] = cursor } 578 + return try await executeQuery("app.bsky.notification.listActivitySubscriptions", parameters: params) 579 + } 580 + 581 + /// Upserts an activity subscription for a subject. 582 + public func putActivitySubscription(subject: String, activitySubscription: [String: Any]) async throws -> JSONValue { 583 + try await executeProcedure( 584 + "app.bsky.notification.putActivitySubscription", 585 + body: [ 586 + "subject": subject, 587 + "activitySubscription": activitySubscription 588 + ] 589 + ) 590 + } 591 + 592 + /// Updates legacy notification priority setting. 593 + public func putNotificationPreferences(priority: Bool) async throws { 594 + let _: EmptyResponse = try await executeProcedure( 595 + "app.bsky.notification.putPreferences", 596 + body: ["priority": priority] 597 + ) 598 + } 599 + 600 + /// Updates v2 notification preference payload. 601 + public func putNotificationPreferencesV2(_ preferences: [String: Any]) async throws -> JSONValue { 602 + try await executeProcedure("app.bsky.notification.putPreferencesV2", body: preferences) 603 + } 604 + 605 + /// Registers a push token. 606 + public func registerPush( 607 + serviceDid: String, 608 + token: String, 609 + platform: String, 610 + appID: String, 611 + ageRestricted: Bool? = nil 612 + ) async throws { 613 + var body: Parameters = [ 614 + "serviceDid": serviceDid, 615 + "token": token, 616 + "platform": platform, 617 + "appId": appID 618 + ] 619 + if let ageRestricted { body["ageRestricted"] = ageRestricted } 620 + let _: EmptyResponse = try await executeProcedure("app.bsky.notification.registerPush", body: body) 621 + } 622 + 623 + /// Unregisters a push token. 624 + public func unregisterPush(serviceDid: String, token: String, platform: String, appID: String) async throws { 625 + let _: EmptyResponse = try await executeProcedure( 626 + "app.bsky.notification.unregisterPush", 627 + body: [ 628 + "serviceDid": serviceDid, 629 + "token": token, 630 + "platform": platform, 631 + "appId": appID 632 + ] 633 + ) 634 + } 635 + 636 + // MARK: - Priority 4: Age Assurance, Unspecced, and Video 637 + 638 + /// Starts age-assurance flow. 639 + public func beginAgeAssurance( 640 + email: String, 641 + language: String? = nil, 642 + countryCode: String? = nil, 643 + regionCode: String? = nil 644 + ) async throws { 645 + var body: Parameters = ["email": email] 646 + if let language { body["language"] = language } 647 + if let countryCode { body["countryCode"] = countryCode } 648 + if let regionCode { body["regionCode"] = regionCode } 649 + let _: EmptyResponse = try await executeProcedure("app.bsky.ageassurance.begin", body: body) 650 + } 651 + 652 + /// Fetches age-assurance service config. 653 + public func getAgeAssuranceConfig() async throws -> JSONValue { 654 + try await executeQuery("app.bsky.ageassurance.getConfig") 655 + } 656 + 657 + /// Fetches age-assurance state. 658 + public func getAgeAssuranceState(countryCode: String? = nil, regionCode: String? = nil) async throws -> JSONValue { 659 + var params: Parameters = [:] 660 + if let countryCode { params["countryCode"] = countryCode } 661 + if let regionCode { params["regionCode"] = regionCode } 662 + return try await executeQuery("app.bsky.ageassurance.getState", parameters: params) 663 + } 664 + 665 + /// Unspecced age-assurance state endpoint. 666 + public func getUnspeccedAgeAssuranceState() async throws -> JSONValue { 667 + try await executeQuery("app.bsky.unspecced.getAgeAssuranceState") 668 + } 669 + 670 + /// Unspecced service config endpoint. 671 + public func getUnspeccedConfig() async throws -> JSONValue { 672 + try await executeQuery("app.bsky.unspecced.getConfig") 673 + } 674 + 675 + public func getOnboardingSuggestedStarterPacks(limit: Int = 25) async throws -> JSONValue { 676 + try await executeQuery("app.bsky.unspecced.getOnboardingSuggestedStarterPacks", parameters: ["limit": limit]) 677 + } 678 + 679 + public func getOnboardingSuggestedStarterPacksSkeleton(viewer: String? = nil, limit: Int = 25) async throws -> JSONValue { 680 + var params: Parameters = ["limit": limit] 681 + if let viewer { params["viewer"] = viewer } 682 + return try await executeQuery("app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton", parameters: params) 683 + } 684 + 685 + public func getPopularFeedGenerators(limit: Int = 50, cursor: String? = nil, query: String? = nil) async throws -> JSONValue { 686 + var params: Parameters = ["limit": limit] 687 + if let cursor { params["cursor"] = cursor } 688 + if let query { params["query"] = query } 689 + return try await executeQuery("app.bsky.unspecced.getPopularFeedGenerators", parameters: params) 690 + } 691 + 692 + public func getPostThreadV2( 693 + anchor: String, 694 + above: Int? = nil, 695 + below: Int? = nil, 696 + branchingFactor: Int? = nil, 697 + sort: String? = nil 698 + ) async throws -> JSONValue { 699 + var params: Parameters = ["anchor": anchor] 700 + if let above { params["above"] = above } 701 + if let below { params["below"] = below } 702 + if let branchingFactor { params["branchingFactor"] = branchingFactor } 703 + if let sort { params["sort"] = sort } 704 + return try await executeQuery("app.bsky.unspecced.getPostThreadV2", parameters: params) 705 + } 706 + 707 + public func getPostThreadOtherV2(anchor: String) async throws -> JSONValue { 708 + try await executeQuery("app.bsky.unspecced.getPostThreadOtherV2", parameters: ["anchor": anchor]) 709 + } 710 + 711 + public func getUnspeccedSuggestedFeeds(limit: Int = 50) async throws -> JSONValue { 712 + try await executeQuery("app.bsky.unspecced.getSuggestedFeeds", parameters: ["limit": limit]) 713 + } 714 + 715 + public func getSuggestedFeedsSkeleton(viewer: String? = nil, limit: Int = 50) async throws -> JSONValue { 716 + var params: Parameters = ["limit": limit] 717 + if let viewer { params["viewer"] = viewer } 718 + return try await executeQuery("app.bsky.unspecced.getSuggestedFeedsSkeleton", parameters: params) 719 + } 720 + 721 + public func getSuggestedStarterPacks(limit: Int = 50) async throws -> JSONValue { 722 + try await executeQuery("app.bsky.unspecced.getSuggestedStarterPacks", parameters: ["limit": limit]) 723 + } 724 + 725 + public func getSuggestedStarterPacksSkeleton(viewer: String? = nil, limit: Int = 50) async throws -> JSONValue { 726 + var params: Parameters = ["limit": limit] 727 + if let viewer { params["viewer"] = viewer } 728 + return try await executeQuery("app.bsky.unspecced.getSuggestedStarterPacksSkeleton", parameters: params) 729 + } 730 + 731 + public func getSuggestedUsers(category: String? = nil, limit: Int = 25) async throws -> JSONValue { 732 + var params: Parameters = ["limit": limit] 733 + if let category { params["category"] = category } 734 + return try await executeQuery("app.bsky.unspecced.getSuggestedUsers", parameters: params) 735 + } 736 + 737 + public func getSuggestedUsersSkeleton(viewer: String? = nil, category: String? = nil, limit: Int = 25) async throws -> JSONValue { 738 + var params: Parameters = ["limit": limit] 739 + if let viewer { params["viewer"] = viewer } 740 + if let category { params["category"] = category } 741 + return try await executeQuery("app.bsky.unspecced.getSuggestedUsersSkeleton", parameters: params) 742 + } 743 + 744 + public func getSuggestionsSkeleton( 745 + viewer: String? = nil, 746 + limit: Int = 50, 747 + cursor: String? = nil, 748 + relativeToDid: String? = nil 749 + ) async throws -> JSONValue { 750 + var params: Parameters = ["limit": limit] 751 + if let viewer { params["viewer"] = viewer } 752 + if let cursor { params["cursor"] = cursor } 753 + if let relativeToDid { params["relativeToDid"] = relativeToDid } 754 + return try await executeQuery("app.bsky.unspecced.getSuggestionsSkeleton", parameters: params) 755 + } 756 + 757 + public func getTaggedSuggestions() async throws -> JSONValue { 758 + try await executeQuery("app.bsky.unspecced.getTaggedSuggestions") 759 + } 760 + 761 + public func getTrendingTopics(viewer: String? = nil, limit: Int = 10) async throws -> JSONValue { 762 + var params: Parameters = ["limit": limit] 763 + if let viewer { params["viewer"] = viewer } 764 + return try await executeQuery("app.bsky.unspecced.getTrendingTopics", parameters: params) 765 + } 766 + 767 + public func getTrends(limit: Int = 10) async throws -> JSONValue { 768 + try await executeQuery("app.bsky.unspecced.getTrends", parameters: ["limit": limit]) 769 + } 770 + 771 + public func getTrendsSkeleton(viewer: String? = nil, limit: Int = 10) async throws -> JSONValue { 772 + var params: Parameters = ["limit": limit] 773 + if let viewer { params["viewer"] = viewer } 774 + return try await executeQuery("app.bsky.unspecced.getTrendsSkeleton", parameters: params) 775 + } 776 + 777 + public func initAgeAssurance(email: String, language: String? = nil, countryCode: String? = nil) async throws { 778 + var body: Parameters = ["email": email] 779 + if let language { body["language"] = language } 780 + if let countryCode { body["countryCode"] = countryCode } 781 + let _: EmptyResponse = try await executeProcedure("app.bsky.unspecced.initAgeAssurance", body: body) 782 + } 783 + 784 + public func searchActorsSkeleton( 785 + query: String, 786 + viewer: String? = nil, 787 + typeahead: Bool? = nil, 788 + limit: Int = 25, 789 + cursor: String? = nil 790 + ) async throws -> JSONValue { 791 + var params: Parameters = ["q": query, "limit": limit] 792 + if let viewer { params["viewer"] = viewer } 793 + if let typeahead { params["typeahead"] = typeahead } 794 + if let cursor { params["cursor"] = cursor } 795 + return try await executeQuery("app.bsky.unspecced.searchActorsSkeleton", parameters: params) 796 + } 797 + 798 + public func searchPostsSkeleton( 799 + query: String, 800 + sort: String? = nil, 801 + since: String? = nil, 802 + until: String? = nil, 803 + mentions: String? = nil, 804 + author: String? = nil, 805 + lang: String? = nil, 806 + domain: String? = nil, 807 + url: String? = nil, 808 + tags: [String]? = nil, 809 + viewer: String? = nil, 810 + limit: Int = 25, 811 + cursor: String? = nil 812 + ) async throws -> JSONValue { 813 + var params: Parameters = ["q": query, "limit": limit] 814 + if let sort { params["sort"] = sort } 815 + if let since { params["since"] = since } 816 + if let until { params["until"] = until } 817 + if let mentions { params["mentions"] = mentions } 818 + if let author { params["author"] = author } 819 + if let lang { params["lang"] = lang } 820 + if let domain { params["domain"] = domain } 821 + if let url { params["url"] = url } 822 + if let tags, !tags.isEmpty { params["tag"] = tags } 823 + if let viewer { params["viewer"] = viewer } 824 + if let cursor { params["cursor"] = cursor } 825 + return try await executeQuery("app.bsky.unspecced.searchPostsSkeleton", parameters: params) 826 + } 827 + 828 + public func searchStarterPacksSkeleton( 829 + query: String, 830 + viewer: String? = nil, 831 + limit: Int = 25, 832 + cursor: String? = nil 833 + ) async throws -> JSONValue { 834 + var params: Parameters = ["q": query, "limit": limit] 835 + if let viewer { params["viewer"] = viewer } 836 + if let cursor { params["cursor"] = cursor } 837 + return try await executeQuery("app.bsky.unspecced.searchStarterPacksSkeleton", parameters: params) 838 + } 839 + 840 + /// Fetches video processing status for a job. 841 + public func getVideoJobStatus(jobID: String) async throws -> JSONValue { 842 + try await executeQuery("app.bsky.video.getJobStatus", parameters: ["jobId": jobID]) 843 + } 844 + 845 + /// Fetches current video upload limits for the authenticated actor. 846 + public func getVideoUploadLimits() async throws -> VideoUploadLimits { 847 + try await executeQuery("app.bsky.video.getUploadLimits") 848 + } 849 + 850 + /// Uploads a video blob for asynchronous processing. 851 + public func uploadVideo(_ data: Data, mimeType: String = "video/mp4") async throws -> JSONValue { 852 + try await executeDataProcedure( 853 + "app.bsky.video.uploadVideo", 854 + data: data, 855 + contentType: mimeType 856 + ) 857 + } 858 + }
+26
Sources/bskyKit/Configuration.swift
··· 1 + import Foundation 2 + import CoreATProtocol 3 + 4 + public enum BskyKitConfigurationError: Error, LocalizedError, Sendable { 5 + case hostNotConfigured 6 + case invalidHostURL(String) 7 + 8 + public var errorDescription: String? { 9 + switch self { 10 + case .hostNotConfigured: 11 + return "AT Protocol host is not configured. Call setup(hostURL:accessJWT:refreshJWT:) first." 12 + case .invalidHostURL(let value): 13 + return "Configured AT Protocol host is not a valid URL: \(value)" 14 + } 15 + } 16 + } 17 + 18 + @APActor 19 + func ensureHostConfigured() throws { 20 + guard let host = APEnvironment.current.host else { 21 + throw BskyKitConfigurationError.hostNotConfigured 22 + } 23 + guard URL(string: host) != nil else { 24 + throw BskyKitConfigurationError.invalidHostURL(host) 25 + } 26 + }
+13
Sources/bskyKit/Models/Feed.swift
··· 26 26 public struct Feeds: Codable, Sendable { 27 27 public let feeds: [Feed] 28 28 } 29 + 30 + /// Paged feed-generator response shape used by multiple endpoints. 31 + public struct FeedPageResponse: Codable, Sendable { 32 + public let feeds: [Feed] 33 + public let cursor: String? 34 + } 35 + 36 + /// Response from app.bsky.feed.getFeedGenerator 37 + public struct FeedGeneratorResponse: Codable, Sendable { 38 + public let view: Feed 39 + public let isOnline: Bool? 40 + public let isValid: Bool? 41 + }
+51
Sources/bskyKit/Models/Graph.swift
··· 46 46 47 47 public var id: String { did } 48 48 } 49 + 50 + /// Response from app.bsky.graph.getRelationships 51 + public struct RelationshipsResponse: Codable, Sendable { 52 + public let actor: String 53 + public let relationships: [RelationshipResult] 54 + } 55 + 56 + public enum RelationshipResult: Codable, Sendable { 57 + case relationship(Relationship) 58 + case notFound(NotFoundActor) 59 + case unknown 60 + 61 + public init(from decoder: Decoder) throws { 62 + if let relationship = try? Relationship(from: decoder) { 63 + self = .relationship(relationship) 64 + return 65 + } 66 + if let notFound = try? NotFoundActor(from: decoder) { 67 + self = .notFound(notFound) 68 + return 69 + } 70 + self = .unknown 71 + } 72 + 73 + public func encode(to encoder: Encoder) throws { 74 + switch self { 75 + case .relationship(let relationship): 76 + try relationship.encode(to: encoder) 77 + case .notFound(let notFound): 78 + try notFound.encode(to: encoder) 79 + case .unknown: 80 + var container = encoder.singleValueContainer() 81 + try container.encode([String: String]()) 82 + } 83 + } 84 + } 85 + 86 + public struct Relationship: Codable, Sendable { 87 + public let did: String 88 + public let following: String? 89 + public let followedBy: String? 90 + public let blocking: String? 91 + public let blockedBy: String? 92 + public let blockingByList: String? 93 + public let blockedByList: String? 94 + } 95 + 96 + public struct NotFoundActor: Codable, Sendable { 97 + public let actor: String 98 + public let notFound: Bool 99 + }
+59
Sources/bskyKit/Models/JSONValue.swift
··· 1 + import Foundation 2 + 3 + public enum JSONValue: Codable, Sendable, Equatable { 4 + case string(String) 5 + case number(Double) 6 + case bool(Bool) 7 + case array([JSONValue]) 8 + case object([String: JSONValue]) 9 + case null 10 + 11 + public init(from decoder: Decoder) throws { 12 + let container = try decoder.singleValueContainer() 13 + 14 + if container.decodeNil() { 15 + self = .null 16 + return 17 + } 18 + if let value = try? container.decode(Bool.self) { 19 + self = .bool(value) 20 + return 21 + } 22 + if let value = try? container.decode(Double.self) { 23 + self = .number(value) 24 + return 25 + } 26 + if let value = try? container.decode(String.self) { 27 + self = .string(value) 28 + return 29 + } 30 + if let value = try? container.decode([JSONValue].self) { 31 + self = .array(value) 32 + return 33 + } 34 + if let value = try? container.decode([String: JSONValue].self) { 35 + self = .object(value) 36 + return 37 + } 38 + 39 + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") 40 + } 41 + 42 + public func encode(to encoder: Encoder) throws { 43 + var container = encoder.singleValueContainer() 44 + switch self { 45 + case .string(let value): 46 + try container.encode(value) 47 + case .number(let value): 48 + try container.encode(value) 49 + case .bool(let value): 50 + try container.encode(value) 51 + case .array(let value): 52 + try container.encode(value) 53 + case .object(let value): 54 + try container.encode(value) 55 + case .null: 56 + try container.encodeNil() 57 + } 58 + } 59 + }
+31 -68
Sources/bskyKit/Models/PostThread.swift
··· 1 - // 2 - // PostThread.swift 3 - // bskyKit 4 - // 5 - // Created by Thomas Rademaker on 01/02/2026. 6 - // 7 - 8 1 import Foundation 9 2 10 3 /// Response from app.bsky.feed.getPostThread 11 4 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 - } 5 + public let thread: ThreadNode 29 6 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 7 + public var threadPost: ThreadViewPost? { 8 + if case .post(let post) = thread { 9 + return post 10 + } 11 + return nil 35 12 } 36 13 } 37 14 38 - /// Parent of a thread post (can be another post or blocked/not found) 39 - public indirect enum ThreadParent: Codable, Sendable { 15 + public enum ThreadNode: Codable, Sendable { 40 16 case post(ThreadViewPost) 41 17 case notFound(NotFoundPost) 42 18 case blocked(BlockedPost) 43 19 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 20 enum CodingKeys: String, CodingKey { 69 21 case type = "$type" 70 22 } 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 23 79 24 public init(from decoder: Decoder) throws { 80 25 let container = try decoder.container(keyedBy: CodingKeys.self) ··· 91 36 92 37 public func encode(to encoder: Encoder) throws { 93 38 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) 39 + case .post(let post): 40 + try post.encode(to: encoder) 41 + case .notFound(let post): 42 + try post.encode(to: encoder) 43 + case .blocked(let post): 44 + try post.encode(to: encoder) 100 45 } 101 46 } 47 + } 48 + 49 + /// A post in a thread with parent/replies context. 50 + public final class ThreadViewPost: Codable, Sendable, Identifiable { 51 + public let type: String? 52 + public let post: Post 53 + public let parent: ThreadNode? 54 + public let replies: [ThreadNode]? 55 + 56 + public var id: String { post.uri } 102 57 103 58 enum CodingKeys: String, CodingKey { 104 59 case type = "$type" 60 + case post, parent, replies 61 + } 62 + 63 + public init(type: String?, post: Post, parent: ThreadNode?, replies: [ThreadNode]?) { 64 + self.type = type 65 + self.post = post 66 + self.parent = parent 67 + self.replies = replies 105 68 } 106 69 } 107 70
+15
Sources/bskyKit/Models/Posts.swift
··· 11 11 public struct Posts: Codable, Sendable { 12 12 public let posts: [Post] 13 13 } 14 + 15 + /// Response from app.bsky.feed.searchPosts 16 + public struct SearchPostsResponse: Codable, Sendable { 17 + public let posts: [Post] 18 + public let cursor: String? 19 + public let hitsTotal: Int? 20 + } 21 + 22 + /// Response from app.bsky.feed.getQuotes 23 + public struct QuotesResponse: Codable, Sendable { 24 + public let uri: String 25 + public let cid: String? 26 + public let posts: [Post] 27 + public let cursor: String? 28 + }
+7
Sources/bskyKit/Models/SearchActors.swift
··· 13 13 public let cursor: String? 14 14 } 15 15 16 + /// Response from app.bsky.actor.getSuggestions 17 + public struct ActorSuggestionsResponse: Codable, Sendable { 18 + public let actors: [ActorProfile] 19 + public let cursor: String? 20 + public let recId: Int? 21 + } 22 + 16 23 /// Response from app.bsky.actor.searchActorsTypeahead 17 24 public struct SearchActorsTypeaheadResult: Codable, Sendable { 18 25 public let actors: [ActorProfile]
+200 -183
Sources/bskyKit/Models/Timeline.swift
··· 1 - // 2 - // Timeline.swift 3 - // bskyKit 4 - // 5 - // Created by Thomas Rademaker on 10/11/25. 6 - // 7 - 8 1 import Foundation 9 2 10 3 public struct Timeline: Codable, Sendable { 11 4 public var feed: [TimelineItem] 12 - public var cursor: String 5 + public var cursor: String? 13 6 } 14 7 15 - public struct TimelineItem: Codable, Sendable { 8 + public struct TimelineItem: Codable, Sendable, Identifiable, Equatable { 16 9 public let post: Post 17 10 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 - } 11 + public let reason: TimelineReason? 12 + public let feedContext: String? 13 + public let reqId: String? 76 14 77 - extension TimelineItem: Identifiable { 78 - /// Stable identifier based on post URI and CID 79 15 public var id: String { 80 - "\(post.uri ?? "")-\(post.cid ?? "")" 16 + "\(post.uri)-\(post.cid)" 81 17 } 82 - } 83 18 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 19 + public static func == (lhs: TimelineItem, rhs: TimelineItem) -> Bool { 20 + lhs.post.uri == rhs.post.uri && lhs.post.cid == rhs.post.cid 21 + } 102 22 } 103 23 104 - public struct Facet: Codable, Sendable { 105 - public let index: FacetIndex 106 - public let features: [FacetFeature] 107 - 108 - } 24 + public enum TimelineReason: Codable, Sendable, Equatable { 25 + case repost(ReasonRepost) 26 + case pin(ReasonPin) 27 + case unknown(String) 109 28 110 - public struct FacetFeature: Codable, Sendable { 111 - public let uri: String? 112 - public let type: FacetType 113 - 114 29 enum CodingKeys: String, CodingKey { 115 - case uri 116 30 case type = "$type" 117 31 } 118 - } 119 32 120 - public enum FacetType: Codable, Sendable { 121 - case link(String) 122 - case unknown(String) 123 - 124 33 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) 34 + let container = try decoder.container(keyedBy: CodingKeys.self) 35 + let type = try container.decode(String.self, forKey: .type) 36 + 37 + switch type { 38 + case "app.bsky.feed.defs#reasonRepost": 39 + self = .repost(try ReasonRepost(from: decoder)) 40 + case "app.bsky.feed.defs#reasonPin": 41 + self = .pin(try ReasonPin(from: decoder)) 42 + default: 43 + self = .unknown(type) 131 44 } 132 45 } 133 - 46 + 134 47 public func encode(to encoder: Encoder) throws { 135 - var container = encoder.singleValueContainer() 136 48 switch self { 137 - case .link(let value), .unknown(let value): 138 - try container.encode(value) 49 + case .repost(let reason): 50 + try reason.encode(to: encoder) 51 + case .pin(let reason): 52 + try reason.encode(to: encoder) 53 + case .unknown(let type): 54 + var container = encoder.container(keyedBy: CodingKeys.self) 55 + try container.encode(type, forKey: .type) 139 56 } 140 57 } 141 58 } 142 59 143 - public struct FacetIndex: Codable, Sendable { 144 - public let byteEnd: Int 145 - public let byteStart: Int 60 + public struct ReasonRepost: Codable, Sendable, Equatable { 61 + public let by: Author 62 + public let indexedAt: Date? 63 + 64 + public static func == (lhs: ReasonRepost, rhs: ReasonRepost) -> Bool { 65 + lhs.by.did == rhs.by.did && 66 + lhs.by.handle == rhs.by.handle && 67 + lhs.indexedAt == rhs.indexedAt 68 + } 69 + } 70 + 71 + public struct ReasonPin: Codable, Sendable, Equatable { 72 + public let by: Author 73 + public let indexedAt: Date? 74 + 75 + public static func == (lhs: ReasonPin, rhs: ReasonPin) -> Bool { 76 + lhs.by.did == rhs.by.did && 77 + lhs.by.handle == rhs.by.handle && 78 + lhs.indexedAt == rhs.indexedAt 79 + } 80 + } 81 + 82 + public struct Post: Codable, Sendable { 83 + public let uri: String 84 + public let cid: String 85 + public let author: Author 86 + public let record: Record 87 + public let embed: Embed? 88 + public let bookmarkCount: Int? 89 + public let replyCount: Int? 90 + public let repostCount: Int? 91 + public let likeCount: Int? 92 + public let quoteCount: Int? 93 + public let indexedAt: Date 94 + public let viewer: FeedViewer? 95 + public let labels: [AuthorLabels]? 96 + public let threadgate: ThreadgateView? 97 + } 98 + 99 + public struct FeedViewer: Codable, Sendable { 100 + public let repost: String? 101 + public let like: String? 102 + public let bookmarked: Bool? 103 + public let threadMuted: Bool? 104 + public let replyDisabled: Bool? 105 + public let embeddingDisabled: Bool? 106 + public let pinned: Bool? 146 107 } 147 108 148 109 public struct Embed: Codable, Sendable { 149 - public let type: String 110 + public let type: String? 150 111 public let images: [EmbeddedMedia]? 151 112 public let media: Media? 152 113 public let record: EmbedRecord? 153 114 public let external: EmbedExternal? 154 - 115 + 155 116 enum CodingKeys: String, CodingKey { 156 117 case images, media, record, external 157 118 case type = "$type" ··· 161 122 public struct EmbedExternal: Codable, Sendable { 162 123 public let uri: String? 163 124 public let thumb: TimelineImage? 164 - public let title: String 165 - public let externalDescription: String 166 - 125 + public let title: String? 126 + public let externalDescription: String? 127 + 167 128 enum CodingKeys: String, CodingKey { 168 129 case uri, thumb, title 169 130 case externalDescription = "description" ··· 177 138 public let cid: String? 178 139 public let author: Author? 179 140 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 - 141 + 185 142 enum CodingKeys: String, CodingKey { 186 143 case type = "$type" 187 - case record, uri, cid, author, value/*, labels, indexedAt, embeds*/ 144 + case record, uri, cid, author, value 188 145 } 189 146 } 190 147 191 148 public struct EmbedRecordValue: Codable, Sendable { 192 - public let text: String 193 - public let type: String 149 + public let type: String? 150 + public let text: String? 194 151 public let langs: [String]? 195 152 public let reply: ReplyDetail? 196 - public let createdAt: String 197 - 153 + public let createdAt: Date? 154 + 198 155 enum CodingKeys: String, CodingKey { 199 156 case type = "$type" 200 - case langs, reply, createdAt, text 157 + case text, langs, reply, createdAt 201 158 } 202 159 } 203 160 204 161 public struct Media: Codable, Sendable { 205 - public let type: String 162 + public let type: String? 206 163 public let images: [EmbeddedMedia]? 207 - 164 + 208 165 enum CodingKeys: String, CodingKey { 209 166 case type = "$type" 210 167 case images 211 168 } 212 169 } 213 170 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 171 public enum TimelineImage: Codable, Sendable, Identifiable { 222 172 case string(String) 223 173 case image(EmbeddedImage) 224 - 174 + 225 175 public init(from decoder: Decoder) throws { 226 176 let container = try decoder.singleValueContainer() 227 - 228 177 if let string = try? container.decode(String.self) { 229 178 self = .string(string) 230 179 return 231 180 } 232 - 233 181 if let image = try? container.decode(EmbeddedImage.self) { 234 182 self = .image(image) 235 183 return 236 184 } 237 - 238 - throw DecodingError.typeMismatch(TimelineImage.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for MyProperty")) 185 + throw DecodingError.typeMismatch( 186 + TimelineImage.self, 187 + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected string or embedded image") 188 + ) 239 189 } 240 - 190 + 241 191 public func encode(to encoder: Encoder) throws { 242 192 var container = encoder.singleValueContainer() 243 193 switch self { ··· 247 197 try container.encode(image) 248 198 } 249 199 } 250 - 251 - /// Stable identifier based on content 200 + 252 201 public var id: String { 253 202 switch self { 254 203 case .string(let value): 255 204 return value 256 - case .image(let img): 257 - return "\(img.type)-\(img.size)" 205 + case .image(let image): 206 + return "\(image.type ?? "image")-\(image.size ?? -1)" 258 207 } 259 208 } 260 209 } ··· 262 211 public struct EmbeddedMedia: Codable, Sendable { 263 212 public let thumb: TimelineImage? 264 213 public let fullsize: String? 265 - public let alt: String 214 + public let alt: String? 266 215 public let aspectRatio: EmbedImageAspectRatio? 267 216 public let image: TimelineImage? 268 217 } 269 218 270 219 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 - 220 + public let type: String? 221 + public let ref: [String: String]? 222 + public let mimeType: String? 223 + public let size: Int? 224 + 276 225 enum CodingKeys: String, CodingKey { 277 226 case type = "$type" 278 227 case ref, mimeType, size ··· 294 243 public let handle: String 295 244 public let displayName: String? 296 245 public let avatar: String? 297 - public let viewer: Viewer 298 - public let labels: [AuthorLabels] 246 + public let viewer: Viewer? 247 + public let labels: [AuthorLabels]? 248 + public let createdAt: Date? 299 249 } 300 250 301 251 public struct AuthorLabels: Codable, Sendable { ··· 307 257 } 308 258 309 259 public struct Record: Codable, Sendable { 310 - public let text: String 311 - public let type: String 260 + public let type: String? 261 + public let text: String? 312 262 public let langs: [String]? 313 263 public let reply: ReplyDetail? 314 - public let createdAt: String 264 + public let createdAt: Date? 315 265 public let embed: Embed? 316 266 public let facets: [Facet]? 317 - 267 + 268 + enum CodingKeys: String, CodingKey { 269 + case type = "$type" 270 + case text, langs, reply, createdAt, embed, facets 271 + } 272 + } 273 + 274 + public struct Facet: Codable, Sendable { 275 + public let index: FacetIndex 276 + public let features: [FacetFeature] 277 + } 278 + 279 + public struct FacetFeature: Codable, Sendable { 280 + public let uri: String? 281 + public let did: String? 282 + public let tag: String? 283 + public let type: FacetType 284 + 318 285 enum CodingKeys: String, CodingKey { 286 + case uri 287 + case did 288 + case tag 319 289 case type = "$type" 320 - case langs, reply, createdAt, embed, text, facets 321 290 } 322 291 } 323 292 293 + public enum FacetType: Codable, Sendable { 294 + case link 295 + case mention 296 + case tag 297 + case unknown(String) 298 + 299 + public init(from decoder: Decoder) throws { 300 + let container = try decoder.singleValueContainer() 301 + let value = try container.decode(String.self) 302 + switch value { 303 + case "app.bsky.richtext.facet#link": 304 + self = .link 305 + case "app.bsky.richtext.facet#mention": 306 + self = .mention 307 + case "app.bsky.richtext.facet#tag": 308 + self = .tag 309 + default: 310 + self = .unknown(value) 311 + } 312 + } 313 + 314 + public func encode(to encoder: Encoder) throws { 315 + var container = encoder.singleValueContainer() 316 + switch self { 317 + case .link: 318 + try container.encode("app.bsky.richtext.facet#link") 319 + case .mention: 320 + try container.encode("app.bsky.richtext.facet#mention") 321 + case .tag: 322 + try container.encode("app.bsky.richtext.facet#tag") 323 + case .unknown(let value): 324 + try container.encode(value) 325 + } 326 + } 327 + } 328 + 329 + public struct FacetIndex: Codable, Sendable { 330 + public let byteEnd: Int 331 + public let byteStart: Int 332 + } 333 + 324 334 public struct ReplyDetail: Codable, Sendable { 325 335 public let root: UnpopulatedPost 326 336 public let parent: UnpopulatedPost ··· 332 342 } 333 343 334 344 public struct Root: Codable, Sendable { 335 - public let type: String 345 + public let type: String? 336 346 public let uri: String? 337 347 public let cid: String? 338 348 public let author: Author 339 349 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 - 350 + public let replyCount: Int? 351 + public let repostCount: Int? 352 + public let likeCount: Int? 353 + public let quoteCount: Int? 354 + public let indexedAt: Date? 355 + public let viewer: FeedViewer? 356 + public let labels: [AuthorLabels]? 357 + 347 358 enum CodingKeys: String, CodingKey { 348 359 case type = "$type" 349 - case uri, cid, author, record, replyCount, repostCount, likeCount, indexedAt, viewer, labels 360 + case uri, cid, author, record, replyCount, repostCount, likeCount, quoteCount, indexedAt, viewer, labels 350 361 } 351 362 } 352 363 353 364 public struct Parent: Codable, Sendable { 354 - public let type: String 365 + public let type: String? 355 366 public let uri: String? 356 367 public let cid: String? 357 368 public let author: Author 358 369 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 - 370 + public let replyCount: Int? 371 + public let repostCount: Int? 372 + public let likeCount: Int? 373 + public let quoteCount: Int? 374 + public let indexedAt: Date? 375 + public let viewer: FeedViewer? 376 + public let labels: [AuthorLabels]? 377 + 366 378 enum CodingKeys: String, CodingKey { 367 379 case type = "$type" 368 - case uri, cid, author, record, replyCount, repostCount, likeCount, indexedAt, viewer, labels 380 + case uri, cid, author, record, replyCount, repostCount, likeCount, quoteCount, indexedAt, viewer, labels 369 381 } 370 382 } 383 + 384 + public struct ThreadgateView: Codable, Sendable { 385 + public let uri: String? 386 + public let cid: String? 387 + }
+10
Sources/bskyKit/Models/Video.swift
··· 1 + import Foundation 2 + 3 + /// Response from app.bsky.video.getUploadLimits 4 + public struct VideoUploadLimits: Codable, Sendable { 5 + public let canUpload: Bool 6 + public let remainingDailyVideos: Int? 7 + public let remainingDailyBytes: Int? 8 + public let message: String? 9 + public let error: String? 10 + }
+48
Sources/bskyKit/Models/Viewer.swift
··· 31 31 self.mutedByList = mutedByList 32 32 self.blockingByList = blockingByList 33 33 } 34 + 35 + enum CodingKeys: String, CodingKey { 36 + case muted 37 + case blockedBy 38 + case following 39 + case followedBy 40 + case blocking 41 + case mutedByList 42 + case blockingByList 43 + } 44 + 45 + public init(from decoder: Decoder) throws { 46 + let container = try decoder.container(keyedBy: CodingKeys.self) 47 + muted = try container.decodeIfPresent(Bool.self, forKey: .muted) 48 + blockedBy = try container.decodeIfPresent(Bool.self, forKey: .blockedBy) 49 + following = try container.decodeIfPresent(String.self, forKey: .following) 50 + followedBy = try container.decodeIfPresent(String.self, forKey: .followedBy) 51 + blocking = try container.decodeIfPresent(String.self, forKey: .blocking) 52 + mutedByList = try container.decodeListURIIfPresent(forKey: .mutedByList) 53 + blockingByList = try container.decodeListURIIfPresent(forKey: .blockingByList) 54 + } 55 + 56 + public func encode(to encoder: Encoder) throws { 57 + var container = encoder.container(keyedBy: CodingKeys.self) 58 + try container.encodeIfPresent(muted, forKey: .muted) 59 + try container.encodeIfPresent(blockedBy, forKey: .blockedBy) 60 + try container.encodeIfPresent(following, forKey: .following) 61 + try container.encodeIfPresent(followedBy, forKey: .followedBy) 62 + try container.encodeIfPresent(blocking, forKey: .blocking) 63 + try container.encodeIfPresent(mutedByList, forKey: .mutedByList) 64 + try container.encodeIfPresent(blockingByList, forKey: .blockingByList) 65 + } 66 + } 67 + 68 + private struct ViewerListReference: Decodable { 69 + let uri: String? 70 + } 71 + 72 + private extension KeyedDecodingContainer where Key: CodingKey { 73 + func decodeListURIIfPresent(forKey key: Key) throws -> String? { 74 + if let uri = try decodeIfPresent(String.self, forKey: key) { 75 + return uri 76 + } 77 + if let list = try decodeIfPresent(ViewerListReference.self, forKey: key) { 78 + return list.uri 79 + } 80 + return nil 81 + } 34 82 }
+46 -7
Sources/bskyKit/RepoAPI.swift
··· 11 11 /// API endpoints for com.atproto.repo.* lexicons 12 12 enum RepoAPI: Sendable { 13 13 case createRecord(body: Data) 14 + case putRecord(body: Data) 15 + case applyWrites(body: Data) 14 16 case deleteRecord(body: Data) 17 + case describeRepo(repo: String) 15 18 case getRecord(repo: String, collection: String, rkey: String) 16 19 case listRecords(repo: String, collection: String, limit: Int, cursor: String?) 17 - // Note: uploadBlob requires CoreATProtocol updates - deferred 20 + case listMissingBlobs(limit: Int, cursor: String?) 21 + case importRepo(data: Data) 22 + case uploadBlob(data: Data, mimeType: String) 18 23 } 19 24 20 25 extension RepoAPI: EndpointType { 21 26 public var baseURL: URL { 22 27 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.") } 28 + guard let host = await APEnvironment.current.host, 29 + let url = URL(string: host) else { 30 + return URL(string: "https://invalid.invalid")! 31 + } 25 32 return url 26 33 } 27 34 } ··· 29 36 var path: String { 30 37 switch self { 31 38 case .createRecord: "/xrpc/com.atproto.repo.createRecord" 39 + case .putRecord: "/xrpc/com.atproto.repo.putRecord" 40 + case .applyWrites: "/xrpc/com.atproto.repo.applyWrites" 32 41 case .deleteRecord: "/xrpc/com.atproto.repo.deleteRecord" 42 + case .describeRepo: "/xrpc/com.atproto.repo.describeRepo" 33 43 case .getRecord: "/xrpc/com.atproto.repo.getRecord" 34 44 case .listRecords: "/xrpc/com.atproto.repo.listRecords" 45 + case .listMissingBlobs: "/xrpc/com.atproto.repo.listMissingBlobs" 46 + case .importRepo: "/xrpc/com.atproto.repo.importRepo" 47 + case .uploadBlob: "/xrpc/com.atproto.repo.uploadBlob" 35 48 } 36 49 } 37 50 38 51 var httpMethod: HTTPMethod { 39 52 switch self { 40 - case .createRecord, .deleteRecord: 53 + case .createRecord, .putRecord, .applyWrites, .deleteRecord, .importRepo, .uploadBlob: 41 54 return .post 42 - case .getRecord, .listRecords: 55 + case .describeRepo, .getRecord, .listRecords, .listMissingBlobs: 43 56 return .get 44 57 } 45 58 } 46 59 47 60 var task: HTTPTask { 48 61 switch self { 49 - case .createRecord(let body), .deleteRecord(let body): 62 + case .createRecord(let body), .putRecord(let body), .applyWrites(let body), .deleteRecord(let body): 50 63 return .requestParameters(encoding: .jsonDataEncoding(data: body)) 51 64 65 + case .importRepo(let data), .uploadBlob(let data, _): 66 + return .requestParameters(encoding: .jsonDataEncoding(data: data)) 67 + 68 + case .describeRepo(let repo): 69 + return .requestParameters(encoding: .urlEncoding(parameters: [ 70 + "repo": repo 71 + ])) 72 + 52 73 case .getRecord(let repo, let collection, let rkey): 53 74 return .requestParameters(encoding: .urlEncoding(parameters: [ 54 75 "repo": repo, ··· 60 81 var params: Parameters = ["repo": repo, "collection": collection, "limit": limit] 61 82 if let cursor { params["cursor"] = cursor } 62 83 return .requestParameters(encoding: .urlEncoding(parameters: params)) 84 + 85 + case .listMissingBlobs(let limit, let cursor): 86 + var params: Parameters = ["limit": limit] 87 + if let cursor { params["cursor"] = cursor } 88 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 63 89 } 64 90 } 65 91 66 92 var headers: HTTPHeaders? { 67 - nil 93 + switch self { 94 + case .importRepo: 95 + return [ 96 + "Content-Type": "application/vnd.ipld.car", 97 + "Accept": "application/json" 98 + ] 99 + case .uploadBlob(_, let mimeType): 100 + return [ 101 + "Content-Type": mimeType, 102 + "Accept": "application/json" 103 + ] 104 + default: 105 + return nil 106 + } 68 107 } 69 108 }
+183 -8
Sources/bskyKit/RepoService.swift
··· 19 19 20 20 public init() {} 21 21 22 + private func execute<T: Decodable>(_ endpoint: RepoAPI) async throws -> T { 23 + try ensureHostConfigured() 24 + return try await router.execute(endpoint) 25 + } 26 + 22 27 // MARK: - Record Operations 23 28 24 29 /// Creates a new record in the repository ··· 36 41 if let rkey { body["rkey"] = rkey } 37 42 38 43 let data = try JSONSerialization.data(withJSONObject: body) 39 - return try await router.execute(.createRecord(body: data)) 44 + return try await execute(.createRecord(body: data)) 45 + } 46 + 47 + /// Writes a record, creating or updating it for the given record key. 48 + public func putRecord( 49 + repo: String, 50 + collection: String, 51 + rkey: String, 52 + record: [String: Any], 53 + validate: Bool? = nil, 54 + swapRecord: String? = nil, 55 + swapCommit: String? = nil 56 + ) async throws -> PutRecordResponse { 57 + var body: [String: Any] = [ 58 + "repo": repo, 59 + "collection": collection, 60 + "rkey": rkey, 61 + "record": record 62 + ] 63 + if let validate { body["validate"] = validate } 64 + if let swapRecord { body["swapRecord"] = swapRecord } 65 + if let swapCommit { body["swapCommit"] = swapCommit } 66 + 67 + let data = try JSONSerialization.data(withJSONObject: body) 68 + return try await execute(.putRecord(body: data)) 69 + } 70 + 71 + /// Applies a batch of create/update/delete writes in a single transaction. 72 + public func applyWrites( 73 + repo: String, 74 + writes: [WriteOperation], 75 + validate: Bool? = nil, 76 + swapCommit: String? = nil 77 + ) async throws -> ApplyWritesResponse { 78 + var body: [String: Any] = [ 79 + "repo": repo, 80 + "writes": writes.map { $0.toRecord() } 81 + ] 82 + if let validate { body["validate"] = validate } 83 + if let swapCommit { body["swapCommit"] = swapCommit } 84 + 85 + let data = try JSONSerialization.data(withJSONObject: body) 86 + return try await execute(.applyWrites(body: data)) 40 87 } 41 88 42 89 /// Deletes a record from the repository ··· 51 98 "rkey": rkey 52 99 ] 53 100 let data = try JSONSerialization.data(withJSONObject: body) 54 - let _: EmptyResponse = try await router.execute(.deleteRecord(body: data)) 101 + let _: EmptyResponse = try await execute(.deleteRecord(body: data)) 102 + } 103 + 104 + /// Describes repository metadata, collection list, and DID document. 105 + public func describeRepo(repo: String) async throws -> DescribeRepoResponse { 106 + try await execute(.describeRepo(repo: repo)) 55 107 } 56 108 57 109 /// Gets a single record ··· 60 112 collection: String, 61 113 rkey: String 62 114 ) async throws -> GetRecordResponse { 63 - try await router.execute(.getRecord(repo: repo, collection: collection, rkey: rkey)) 115 + try await execute(.getRecord(repo: repo, collection: collection, rkey: rkey)) 64 116 } 65 117 66 118 /// Lists records in a collection ··· 70 122 limit: Int = 50, 71 123 cursor: String? = nil 72 124 ) async throws -> ListRecordsResponse { 73 - try await router.execute(.listRecords(repo: repo, collection: collection, limit: limit, cursor: cursor)) 125 + try await execute(.listRecords(repo: repo, collection: collection, limit: limit, cursor: cursor)) 126 + } 127 + 128 + /// Lists missing blobs for import/migration workflows. 129 + public func listMissingBlobs(limit: Int = 500, cursor: String? = nil) async throws -> ListMissingBlobsResponse { 130 + try await execute(.listMissingBlobs(limit: limit, cursor: cursor)) 131 + } 132 + 133 + /// Imports a repository archive (CAR format). 134 + public func importRepo(car: Data) async throws { 135 + let _: EmptyResponse = try await execute(.importRepo(data: car)) 74 136 } 75 137 76 - // Note: uploadBlob deferred until CoreATProtocol is updated 138 + /// Uploads a blob to be referenced by later record writes. 139 + public func uploadBlob(data: Data, mimeType: String) async throws -> BlobResponse { 140 + try await execute(.uploadBlob(data: data, mimeType: mimeType)) 141 + } 77 142 78 143 // MARK: - High-Level Operations 79 144 ··· 173 238 public let cid: String 174 239 } 175 240 241 + public struct CommitMeta: Codable, Sendable { 242 + public let cid: String 243 + public let rev: String 244 + } 245 + 246 + public struct PutRecordResponse: Codable, Sendable { 247 + public let uri: String 248 + public let cid: String 249 + public let commit: CommitMeta? 250 + public let validationStatus: String? 251 + } 252 + 253 + public struct ApplyWritesResponse: Codable, Sendable { 254 + public let commit: CommitMeta? 255 + public let results: [ApplyWritesResult]? 256 + } 257 + 258 + public enum ApplyWritesResult: Codable, Sendable { 259 + case create(CreateRecordResponse) 260 + case update(CreateRecordResponse) 261 + case delete 262 + case unknown 263 + 264 + public init(from decoder: Decoder) throws { 265 + if let result = try? CreateRecordResponse(from: decoder) { 266 + self = .create(result) 267 + return 268 + } 269 + 270 + let container = try decoder.singleValueContainer() 271 + if (try? container.decode([String: String].self))?.isEmpty == true { 272 + self = .delete 273 + return 274 + } 275 + 276 + self = .unknown 277 + } 278 + 279 + public func encode(to encoder: Encoder) throws { 280 + switch self { 281 + case .create(let response), .update(let response): 282 + try response.encode(to: encoder) 283 + case .delete: 284 + var container = encoder.singleValueContainer() 285 + try container.encode([String: String]()) 286 + case .unknown: 287 + var container = encoder.singleValueContainer() 288 + try container.encode([String: String]()) 289 + } 290 + } 291 + } 292 + 176 293 public struct GetRecordResponse: Codable, Sendable { 177 294 public let uri: String 178 295 public let cid: String? ··· 195 312 public let cursor: String? 196 313 } 197 314 315 + public struct DescribeRepoResponse: Codable, Sendable { 316 + public let handle: String 317 + public let did: String 318 + public let didDoc: JSONValue? 319 + public let collections: [String] 320 + public let handleIsCorrect: Bool 321 + } 322 + 323 + public struct ListMissingBlobsResponse: Codable, Sendable { 324 + public let cursor: String? 325 + public let blobs: [MissingBlob] 326 + } 327 + 328 + public struct MissingBlob: Codable, Sendable { 329 + public let cid: String 330 + public let recordUri: String 331 + } 332 + 198 333 public struct RecordItem: Codable, Sendable { 199 334 public let uri: String 200 335 public let cid: String ··· 225 360 } 226 361 } 227 362 363 + public enum WriteOperation { 364 + case create(collection: String, rkey: String? = nil, value: [String: Any]) 365 + case update(collection: String, rkey: String, value: [String: Any]) 366 + case delete(collection: String, rkey: String) 367 + 368 + fileprivate func toRecord() -> [String: Any] { 369 + switch self { 370 + case .create(let collection, let rkey, let value): 371 + var record: [String: Any] = [ 372 + "$type": "com.atproto.repo.applyWrites#create", 373 + "collection": collection, 374 + "value": value 375 + ] 376 + if let rkey { record["rkey"] = rkey } 377 + return record 378 + case .update(let collection, let rkey, let value): 379 + return [ 380 + "$type": "com.atproto.repo.applyWrites#update", 381 + "collection": collection, 382 + "rkey": rkey, 383 + "value": value 384 + ] 385 + case .delete(let collection, let rkey): 386 + return [ 387 + "$type": "com.atproto.repo.applyWrites#delete", 388 + "collection": collection, 389 + "rkey": rkey 390 + ] 391 + } 392 + } 393 + } 394 + 228 395 // MARK: - Post Record 229 396 230 397 /// A record for creating a post ··· 275 442 ] 276 443 277 444 if let facets, !facets.isEmpty { 278 - record["facets"] = facets.map { facet in 445 + let encodedFacets: [[String: Any]] = facets.compactMap { facet in 279 446 var dict: [String: Any] = [ 280 447 "index": [ 281 448 "byteStart": facet.index.byteStart, 282 449 "byteEnd": facet.index.byteEnd 283 450 ] 284 451 ] 285 - dict["features"] = facet.features.map { feature -> [String: Any] in 452 + let features: [[String: Any]] = facet.features.compactMap { feature in 286 453 switch feature { 287 454 case .link(let link): 288 455 return ["$type": "app.bsky.richtext.facet#link", "uri": link.uri] 289 456 case .mention(let mention): 290 - return ["$type": "app.bsky.richtext.facet#mention", "did": mention.did ?? ""] 457 + guard let did = mention.did else { return nil } 458 + return ["$type": "app.bsky.richtext.facet#mention", "did": did] 291 459 case .tag(let tag): 292 460 return ["$type": "app.bsky.richtext.facet#tag", "tag": tag.tag] 461 + case .unknown: 462 + return nil 293 463 } 294 464 } 465 + guard !features.isEmpty else { return nil } 466 + dict["features"] = features 295 467 return dict 468 + } 469 + if !encodedFacets.isEmpty { 470 + record["facets"] = encodedFacets 296 471 } 297 472 } 298 473
+23 -10
Sources/bskyKit/RichText/RichText.swift
··· 275 275 /// A hashtag for discovery. 276 276 case tag(RichTextTag) 277 277 278 + /// Unknown facet feature type for forward compatibility. 279 + case unknown(String) 280 + 278 281 enum CodingKeys: String, CodingKey { 279 282 case type = "$type" 280 283 case uri ··· 297 300 let tag = try container.decode(String.self, forKey: .tag) 298 301 self = .tag(RichTextTag(tag: tag)) 299 302 default: 300 - throw DecodingError.dataCorrupted( 301 - DecodingError.Context( 302 - codingPath: decoder.codingPath, 303 - debugDescription: "Unknown facet type: \(type)" 304 - ) 305 - ) 303 + self = .unknown(type) 306 304 } 307 305 } 308 306 ··· 313 311 try container.encode("app.bsky.richtext.facet#link", forKey: .type) 314 312 try container.encode(link.uri, forKey: .uri) 315 313 case .mention(let mention): 314 + guard let did = mention.did else { 315 + throw EncodingError.invalidValue( 316 + mention, 317 + EncodingError.Context( 318 + codingPath: encoder.codingPath, 319 + debugDescription: "Mention facet requires a resolved DID." 320 + ) 321 + ) 322 + } 316 323 try container.encode("app.bsky.richtext.facet#mention", forKey: .type) 317 - try container.encode(mention.did ?? mention.handle, forKey: .did) 324 + try container.encode(did, forKey: .did) 318 325 case .tag(let tag): 319 326 try container.encode("app.bsky.richtext.facet#tag", forKey: .type) 320 327 try container.encode(tag.tag, forKey: .tag) 328 + case .unknown(let type): 329 + try container.encode(type, forKey: .type) 321 330 } 322 331 } 323 332 } ··· 386 395 ] 387 396 ] 388 397 389 - let features: [[String: Any]] = facet.features.map { feature in 398 + let features: [[String: Any]] = facet.features.compactMap { feature in 390 399 switch feature { 391 400 case .link(let link): 392 401 return ["$type": "app.bsky.richtext.facet#link", "uri": link.uri] ··· 394 403 if let did = mention.did { 395 404 return ["$type": "app.bsky.richtext.facet#mention", "did": did] 396 405 } 397 - return [:] 406 + return nil 398 407 case .tag(let tag): 399 408 return ["$type": "app.bsky.richtext.facet#tag", "tag": tag.tag] 409 + case .unknown: 410 + return nil 400 411 } 401 412 } 413 + 414 + guard !features.isEmpty else { return [:] } 402 415 403 416 dict["features"] = features 404 417 return dict 405 - } 418 + }.filter { !$0.isEmpty } 406 419 } 407 420 }
+159 -1
Tests/bskyKitTests/ModelDecodingTests.swift
··· 177 177 """ 178 178 179 179 let notification = try decode(Notification.self, from: json) 180 - #expect(notification.reason != nil) 180 + #expect(notification.reason != .unknown(reason)) 181 181 } 182 182 } 183 183 ··· 216 216 #expect(result.actors.count == 1) 217 217 #expect(result.actors[0].handle == "actor1.bsky.social") 218 218 #expect(result.cursor == "next") 219 + } 220 + 221 + @Test("Decodes actor suggestions response") 222 + func decodesActorSuggestionsResponse() throws { 223 + let json = """ 224 + { 225 + "actors": [ 226 + { 227 + "did": "did:plc:actor1", 228 + "handle": "actor1.bsky.social" 229 + } 230 + ], 231 + "cursor": "next", 232 + "recId": 42 233 + } 234 + """ 235 + 236 + let result = try decode(ActorSuggestionsResponse.self, from: json) 237 + #expect(result.actors.count == 1) 238 + #expect(result.actors[0].did == "did:plc:actor1") 239 + #expect(result.cursor == "next") 240 + #expect(result.recId == 42) 241 + } 242 + 243 + // MARK: - Feed and Search 244 + 245 + @Test("Decodes search posts response") 246 + func decodesSearchPostsResponse() throws { 247 + let json = """ 248 + { 249 + "cursor": "next", 250 + "hitsTotal": 1, 251 + "posts": [ 252 + { 253 + "uri": "at://did:plc:author/app.bsky.feed.post/1", 254 + "cid": "bafy-post", 255 + "author": { 256 + "did": "did:plc:author", 257 + "handle": "author.bsky.social" 258 + }, 259 + "record": { 260 + "$type": "app.bsky.feed.post", 261 + "text": "Hello world", 262 + "createdAt": "2024-01-15T10:30:00.000Z" 263 + }, 264 + "indexedAt": "2024-01-15T10:30:00.000Z" 265 + } 266 + ] 267 + } 268 + """ 269 + 270 + let result = try decode(SearchPostsResponse.self, from: json) 271 + #expect(result.posts.count == 1) 272 + #expect(result.hitsTotal == 1) 273 + #expect(result.cursor == "next") 274 + } 275 + 276 + @Test("Decodes relationships response with union members") 277 + func decodesRelationshipsResponse() throws { 278 + let json = """ 279 + { 280 + "actor": "did:plc:me", 281 + "relationships": [ 282 + { 283 + "did": "did:plc:alice", 284 + "following": "at://did:plc:me/app.bsky.graph.follow/abc" 285 + }, 286 + { 287 + "actor": "did:plc:missing", 288 + "notFound": true 289 + } 290 + ] 291 + } 292 + """ 293 + 294 + let relationships = try decode(RelationshipsResponse.self, from: json) 295 + #expect(relationships.actor == "did:plc:me") 296 + #expect(relationships.relationships.count == 2) 297 + 298 + if case .relationship(let relationship) = relationships.relationships[0] { 299 + #expect(relationship.did == "did:plc:alice") 300 + } else { 301 + Issue.record("Expected first relationship entry to decode as relationship") 302 + } 303 + 304 + if case .notFound(let missing) = relationships.relationships[1] { 305 + #expect(missing.actor == "did:plc:missing") 306 + #expect(missing.notFound == true) 307 + } else { 308 + Issue.record("Expected second relationship entry to decode as notFound") 309 + } 310 + } 311 + 312 + // MARK: - Repo 313 + 314 + @Test("Decodes describe repo response with didDoc") 315 + func decodesDescribeRepoResponse() throws { 316 + let json = """ 317 + { 318 + "handle": "alice.bsky.social", 319 + "did": "did:plc:alice", 320 + "didDoc": { 321 + "id": "did:plc:alice", 322 + "service": [ 323 + { 324 + "id": "#atproto_pds", 325 + "type": "AtprotoPersonalDataServer" 326 + } 327 + ] 328 + }, 329 + "collections": ["app.bsky.feed.post"], 330 + "handleIsCorrect": true 331 + } 332 + """ 333 + 334 + let response = try decode(DescribeRepoResponse.self, from: json) 335 + #expect(response.handle == "alice.bsky.social") 336 + #expect(response.did == "did:plc:alice") 337 + #expect(response.collections == ["app.bsky.feed.post"]) 338 + #expect(response.handleIsCorrect == true) 339 + #expect(response.didDoc != nil) 340 + } 341 + 342 + @Test("Decodes list missing blobs response") 343 + func decodesListMissingBlobsResponse() throws { 344 + let json = """ 345 + { 346 + "cursor": "next", 347 + "blobs": [ 348 + { 349 + "cid": "bafkreiabc", 350 + "recordUri": "at://did:plc:alice/app.bsky.feed.post/1" 351 + } 352 + ] 353 + } 354 + """ 355 + 356 + let response = try decode(ListMissingBlobsResponse.self, from: json) 357 + #expect(response.cursor == "next") 358 + #expect(response.blobs.count == 1) 359 + #expect(response.blobs[0].cid == "bafkreiabc") 360 + } 361 + 362 + @Test("Decodes video upload limits") 363 + func decodesVideoUploadLimits() throws { 364 + let json = """ 365 + { 366 + "canUpload": true, 367 + "remainingDailyVideos": 3, 368 + "remainingDailyBytes": 10485760 369 + } 370 + """ 371 + 372 + let limits = try decode(VideoUploadLimits.self, from: json) 373 + #expect(limits.canUpload == true) 374 + #expect(limits.remainingDailyVideos == 3) 375 + #expect(limits.remainingDailyBytes == 10485760) 376 + #expect(limits.error == nil) 219 377 } 220 378 221 379 // MARK: - Likes
+1
Tests/bskyKitTests/RichTextTests.swift
··· 120 120 case .mention: hasMention = true 121 121 case .link: hasLink = true 122 122 case .tag: hasTag = true 123 + case .unknown: break 123 124 } 124 125 } 125 126