Skip to content

Commit acf019b

Browse files
committed
Merge remote-tracking branch 'refs/remotes/origin/main'
2 parents 8d847e4 + 1681f15 commit acf019b

25 files changed

Lines changed: 1525 additions & 62 deletions

Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,72 @@ extension ATProtoBluesky {
240240
/// }
241241
/// ```
242242
///
243+
/// # Adding Audience Controls
244+
/// You can control who can reply to your post and whether it can be embedded by other posts.
245+
///
246+
/// ## Controlling Replies (Threadgate)
247+
/// Use the `replyControls` parameter to restrict who can reply:
248+
///
249+
/// ```swift
250+
/// do {
251+
/// let postResult = try await atProtoBluesky.createPostRecord(
252+
/// text: "Only my followers can reply to this post!",
253+
/// replyControls: [.allowFollowers]
254+
/// )
255+
///
256+
/// print(postResult)
257+
/// } catch {
258+
/// throw error
259+
/// }
260+
/// ```
261+
///
262+
/// You can combine multiple rules:
263+
///
264+
/// ```swift
265+
/// do {
266+
/// let postResult = try await atProtoBluesky.createPostRecord(
267+
/// text: "Only people I follow or who follow me can reply.",
268+
/// replyControls: [.allowFollowers, .allowFollowing]
269+
/// )
270+
///
271+
/// print(postResult)
272+
/// } catch {
273+
/// throw error
274+
/// }
275+
/// ```
276+
///
277+
/// ## Disabling Embedding (Postgate)
278+
/// Use the `embeddingRules` parameter to prevent others from quoting your post:
279+
///
280+
/// ```swift
281+
/// do {
282+
/// let postResult = try await atProtoBluesky.createPostRecord(
283+
/// text: "This post cannot be quoted by others.",
284+
/// embeddingRules: [.disable]
285+
/// )
286+
///
287+
/// print(postResult)
288+
/// } catch {
289+
/// throw error
290+
/// }
291+
/// ```
292+
///
293+
/// You can combine both audience controls:
294+
///
295+
/// ```swift
296+
/// do {
297+
/// let postResult = try await atProtoBluesky.createPostRecord(
298+
/// text: "Followers only replies, no quotes allowed.",
299+
/// replyControls: [.allowFollowers],
300+
/// embeddingRules: [.disable]
301+
/// )
302+
///
303+
/// print(postResult)
304+
/// } catch {
305+
/// throw error
306+
/// }
307+
/// ```
308+
///
243309
/// - Parameters:
244310
/// - text: The text that's directly displayed in the post record. Current limit is
245311
/// 300 characters.
@@ -254,6 +320,10 @@ extension ATProtoBluesky {
254320
/// - labels: An array of labels made by the user. Optional. Defaults to `nil`.
255321
/// - tags: An array of tags for the post record. Optional. Defaults to `nil`.
256322
/// - creationDate: The date of the post record. Defaults to `Date.now`.
323+
/// - replyControls: An array of rules to control who can reply to the post. Optional.
324+
/// Defaults to `nil`. When provided, a threadgate record is created for the post.
325+
/// - embeddingRules: An array of rules to control embedding of the post. Optional.
326+
/// Defaults to `nil`. When provided, a postgate record is created for the post.
257327
/// - recordKey: The record key of the collection. Optional. Defaults to `nil`.
258328
/// - shouldValidate: Indicates whether the record should be validated. Optional.
259329
/// Defaults to `true`.
@@ -268,6 +338,8 @@ extension ATProtoBluesky {
268338
labels: ComAtprotoLexicon.Label.SelfLabelsDefinition? = nil,
269339
tags: [String]? = nil,
270340
creationDate: Date = Date(),
341+
replyControls: [ThreadgateAllowRule]? = nil,
342+
embeddingRules: [PostgateEmbeddingRule]? = nil,
271343
recordKey: String? = nil,
272344
shouldValidate: Bool? = true,
273345
swapCommit: String? = nil
@@ -432,6 +504,81 @@ extension ATProtoBluesky {
432504

433505
try await Task.sleep(nanoseconds: 500_000_000)
434506

507+
// Create threadgate record if reply controls are specified.
508+
// nil = no threadgate (everyone can reply)
509+
// [] = threadgate with empty allow (nobody can reply)
510+
// [rules] = threadgate with those rules
511+
if let replyControls = replyControls {
512+
let postRecordKey = try ATProtoTools().parseURI(record.recordURI).recordKey
513+
514+
var threadgateAllowArray: [AppBskyLexicon.Feed.ThreadgateRecord.ThreadgateUnion] = []
515+
let cappedReplyControls = Array(replyControls.prefix(5))
516+
517+
for replyControl in cappedReplyControls {
518+
switch replyControl {
519+
case .allowMentions:
520+
threadgateAllowArray.append(.mentionRule(AppBskyLexicon.Feed.ThreadgateRecord.MentionRule()))
521+
case .allowFollowers:
522+
threadgateAllowArray.append(.followerRule(AppBskyLexicon.Feed.ThreadgateRecord.FollowerRule()))
523+
case .allowFollowing:
524+
threadgateAllowArray.append(.followingRule(AppBskyLexicon.Feed.ThreadgateRecord.FollowingRule()))
525+
case .allowList(listURI: let listURI):
526+
threadgateAllowArray.append(.listRule(AppBskyLexicon.Feed.ThreadgateRecord.ListRule(listURI: listURI)))
527+
}
528+
}
529+
530+
// Empty allow array means nobody can reply
531+
let threadgateRecord = AppBskyLexicon.Feed.ThreadgateRecord(
532+
postURI: record.recordURI,
533+
allow: threadgateAllowArray,
534+
createdAt: creationDate,
535+
hiddenReplies: nil
536+
)
537+
538+
_ = try await atProtoKitInstance.createRecord(
539+
repositoryDID: session.sessionDID,
540+
collection: "app.bsky.feed.threadgate",
541+
recordKey: postRecordKey,
542+
shouldValidate: shouldValidate,
543+
record: UnknownType.record(threadgateRecord),
544+
swapCommit: nil
545+
)
546+
547+
try await Task.sleep(nanoseconds: 500_000_000)
548+
}
549+
550+
// Create postgate record if embedding rules are specified.
551+
if let embeddingRules = embeddingRules, embeddingRules.isEmpty == false {
552+
let postRecordKey = try ATProtoTools().parseURI(record.recordURI).recordKey
553+
554+
var postgateEmbedRules: [AppBskyLexicon.Feed.PostgateRecord.EmbeddingRulesUnion] = []
555+
556+
for rule in embeddingRules {
557+
switch rule {
558+
case .disable:
559+
postgateEmbedRules.append(.disabledRule(AppBskyLexicon.Feed.PostgateRecord.DisableRule()))
560+
}
561+
}
562+
563+
let postgateRecord = AppBskyLexicon.Feed.PostgateRecord(
564+
createdAt: creationDate,
565+
postURI: record.recordURI,
566+
detachedEmbeddingURIs: nil,
567+
embeddingRules: postgateEmbedRules.isEmpty ? nil : postgateEmbedRules
568+
)
569+
570+
_ = try await atProtoKitInstance.createRecord(
571+
repositoryDID: session.sessionDID,
572+
collection: "app.bsky.feed.postgate",
573+
recordKey: postRecordKey,
574+
shouldValidate: shouldValidate,
575+
record: UnknownType.record(postgateRecord),
576+
swapCommit: nil
577+
)
578+
579+
try await Task.sleep(nanoseconds: 500_000_000)
580+
}
581+
435582
return record
436583
} catch {
437584
throw error
@@ -468,7 +615,7 @@ extension ATProtoBluesky {
468615
)
469616

470617
let embedImage = AppBskyLexicon.Embed.ImagesDefinition.Image(
471-
imageBlob: blobReference.blob,
618+
imageBlob: blobReference,
472619
altText: image.altText ?? "",
473620
aspectRatio: image.aspectRatio
474621
)
@@ -634,7 +781,7 @@ extension ATProtoBluesky {
634781
imageData: caption.file
635782
)
636783

637-
captionReferences.append(AppBskyLexicon.Embed.VideoDefinition.Caption(language: caption.language.identifier, fileBlob: blobReference.blob))
784+
captionReferences.append(AppBskyLexicon.Embed.VideoDefinition.Caption(language: caption.language.identifier, fileBlob: blobReference))
638785
}
639786
}
640787

@@ -688,7 +835,7 @@ extension ATProtoBluesky {
688835
accessToken: accessToken,
689836
filename: "\(ATProtoTools().generateRandomString())_thumbnail.jpg",
690837
imageData: imageData
691-
).blob
838+
)
692839
} else {
693840
thumbnailImage = nil
694841
}

Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/CreateProfileRecord.swift

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ extension ATProtoBluesky {
136136
websiteURL: URL? = nil,
137137
avatarImage: ATProtoTools.ImageQuery? = nil,
138138
bannerImage: ATProtoTools.ImageQuery? = nil,
139-
labels: [ComAtprotoLexicon.Label.SelfLabelsDefinition]? = nil,
139+
labels: ComAtprotoLexicon.Label.SelfLabelsDefinition? = nil,
140140
joinedViaStarterPack: ComAtprotoLexicon.Repository.StrongReference? = nil,
141141
pinnedPost: ComAtprotoLexicon.Repository.StrongReference? = nil,
142142
recordKey: String? = nil,
@@ -214,22 +214,14 @@ extension ATProtoBluesky {
214214
}
215215
}
216216

217-
// labels
218-
var labelsArray: [AppBskyLexicon.Actor.ProfileRecord.LabelsUnion]? = nil
219-
if let labels = labels {
220-
for label in labels {
221-
labelsArray?.append(.selfLabel(label))
222-
}
223-
}
224-
225217
let profileRecord = AppBskyLexicon.Actor.ProfileRecord(
226218
displayName: displayNameText,
227219
description: descriptionText,
228220
pronouns: pronounsText,
229221
websiteURL: website,
230222
avatarBlob: profileAvatarImage,
231223
bannerBlob: profileBannerImage,
232-
labels: labelsArray,
224+
labels: (labels != nil) ? .selfLabel(labels!) : nil,
233225
joinedViaStarterPack: joinedViaStarterPack,
234226
pinnedPost: pinnedPost,
235227
createdAt: Date()

Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/UpdateProfileRecord.swift

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ extension ATProtoBluesky {
7373
var newWebsiteURL: URL? = profile.websiteURL
7474
var newAvatarImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = profile.avatarBlob
7575
var newBannerImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = profile.bannerBlob
76-
var newLabels: [AppBskyLexicon.Actor.ProfileRecord.LabelsUnion]? = profile.labels
76+
var newLabels: AppBskyLexicon.Actor.ProfileRecord.LabelsUnion? = profile.labels
7777
var newJoinedViaStarterPack: ComAtprotoLexicon.Repository.StrongReference? = profile.joinedViaStarterPack
7878
var newPinnedPost: ComAtprotoLexicon.Repository.StrongReference? = profile.pinnedPost
7979

@@ -161,17 +161,12 @@ extension ATProtoBluesky {
161161
}
162162
case .labels(let labelsField):
163163
// Check if the field is nil. If so, set the labels variable to nil and break out of the case early.
164-
if labelsField == nil {
164+
if let labelsField {
165+
newLabels = .selfLabel(labelsField)
166+
} else {
165167
newLabels = nil
166-
167-
break
168-
}
169-
170-
if let labelsField = labelsField {
171-
for labelField in labelsField {
172-
newLabels?.append(.selfLabel(labelField))
173-
}
174168
}
169+
175170
case .joinedViaStarterPack(let joinedViaStarterPackField):
176171
// Check if the field is nil. If so, set the joinedViaStarterPack variable to nil and break out of the case early.
177172
if joinedViaStarterPackField == nil {
@@ -250,7 +245,7 @@ extension ATProtoBluesky {
250245
/// An array of user-defined labels.
251246
///
252247
/// - Parameter with: The object to update the record with. Optional. Defaults to `nil`.
253-
case labels(with: [ComAtprotoLexicon.Label.SelfLabelsDefinition]? = nil)
248+
case labels(with: ComAtprotoLexicon.Label.SelfLabelsDefinition? = nil)
254249

255250
/// A strong reference to the starter pack the user used to join Bluesky.
256251
///

Sources/ATProtoKit/APIReference/AppBskyAPI/AppBskyBookmarkGetBookmarkMethod.swift renamed to Sources/ATProtoKit/APIReference/AppBskyAPI/AppBskyBookmarkGetBookmarksMethod.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ extension ATProtoKit {
4343
let accessToken = try await keychain.retrieveAccessToken()
4444
let sessionURL = session.serviceEndpoint.absoluteString
4545

46-
guard let requestURL = URL(string: "\(sessionURL)/xrpc/app.bsky.bookmark.getBookmark") else {
46+
guard let requestURL = URL(string: "\(sessionURL)/xrpc/app.bsky.bookmark.getBookmarks") else {
4747
throw ATRequestPrepareError.invalidRequestURL
4848
}
4949

Sources/ATProtoKit/APIReference/ComAtprotoAPI/ComAtprotoRepoUploadBlobMethod.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ extension ATProtoKit {
2828
/// - accessToken: The access token for authorization.
2929
/// - filename: The filename of the blob to upload.
3030
/// - imageData: The data of the blob to upload.
31-
/// - Returns: A `BlobContainer` instance with the upload result.
31+
/// - Returns: An `UploadBlobOutput` instance with the upload result.
3232
///
3333
/// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to
3434
/// ``ATAPIError`` and ``ATRequestPrepareError`` for more details.
@@ -37,7 +37,7 @@ extension ATProtoKit {
3737
accessToken: String,
3838
filename: String,
3939
imageData: Data
40-
) async throws -> ComAtprotoLexicon.Repository.BlobContainer {
40+
) async throws -> ComAtprotoLexicon.Repository.UploadBlobOutput {
4141
guard let requestURL = URL(string: "\(pdsURL)/xrpc/com.atproto.repo.uploadBlob") else {
4242
throw ATRequestPrepareError.invalidRequestURL
4343
}
@@ -52,12 +52,22 @@ extension ATProtoKit {
5252
authorizationValue: "Bearer \(accessToken)")
5353
request.httpBody = imageData
5454

55+
// The `com.atproto.repo.uploadBlob` endpoint wraps its result in a
56+
// `blob` key (`{"blob": {...}}`), unlike record fields where the blob
57+
// appears inline. Decode the wrapper and return its blob so uploads
58+
// don't fail looking for `ref` at the response root.
5559
let response = try await apiClientService.sendRequest(request,
56-
decodeTo: ComAtprotoLexicon.Repository.BlobContainer.self)
60+
decodeTo: UploadBlobResponse.self)
5761

58-
return response
62+
return response.blob
5963
} catch {
6064
throw error
6165
}
6266
}
6367
}
68+
69+
/// The response envelope for `com.atproto.repo.uploadBlob`, which returns the
70+
/// uploaded blob under a `blob` key.
71+
private struct UploadBlobResponse: Decodable {
72+
let blob: ComAtprotoLexicon.Repository.UploadBlobOutput
73+
}

Sources/ATProtoKit/APIReference/SessionManager/AppleSecureKeychain.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,13 @@ public actor AppleSecureKeychain: SecureKeychainProtocol {
181181

182182
/// Saves or updates a keychain item.
183183
///
184+
/// Items are stored with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` so that
185+
/// background tasks (e.g. push-driven refresh) can read session tokens while the device
186+
/// is locked. Without this attribute, the keychain default is `kSecAttrAccessibleWhenUnlocked`,
187+
/// which causes `SecItemCopyMatching` to return `errSecInteractionNotAllowed` for any
188+
/// access attempted while the screen is locked — silently breaking background refresh.
189+
/// `ThisDeviceOnly` prevents tokens from migrating via iCloud Keychain or encrypted backups.
190+
///
184191
/// - Parameters:
185192
/// - value: The keychain value.
186193
/// - key: The keychain key.
@@ -195,8 +202,13 @@ public actor AppleSecureKeychain: SecureKeychainProtocol {
195202
kSecAttrService: serviceName,
196203
]
197204

205+
// Including `kSecAttrAccessible` in the update dictionary upgrades the accessibility
206+
// of items previously written with the default attribute, so existing installs
207+
// migrate to the background-readable policy on the next save without needing a
208+
// delete+re-add cycle.
198209
let updateAttributes: [CFString: Any] = [
199-
kSecValueData: data
210+
kSecValueData: data,
211+
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
200212
]
201213

202214
let status = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary)
@@ -207,6 +219,7 @@ public actor AppleSecureKeychain: SecureKeychainProtocol {
207219
case errSecItemNotFound:
208220
var newItem = query
209221
newItem[kSecValueData] = data
222+
newItem[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
210223
let addStatus = SecItemAdd(newItem as CFDictionary, nil)
211224
guard addStatus == errSecSuccess else {
212225
throw ApplSecureKeychainError.unhandledStatus(status: addStatus)

Sources/ATProtoKit/ATProtoKit.docc/Extensions/Lexicons/Models/com.atproto/ComAtprotoRepository.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,5 @@
5757
### com.atproto.repo.uploadBlob
5858

5959
- ``ComAtprotoLexicon/Repository/UploadBlobRequestBody``
60-
- ``ComAtprotoLexicon/Repository/BlobContainer``
6160
- ``ComAtprotoLexicon/Repository/UploadBlobOutput``
6261
- ``ComAtprotoLexicon/Repository/BlobReference``

0 commit comments

Comments
 (0)