Calls reach grain-serve by its own method names
Grain/API/Endpoints/DiscoveryEndpoints.swift+2−2
| @@ -57,7 +57,7 @@ extension XRPCClient { | ||
/// asks for the lean one. | ||
func getLocations(pins: Bool = false, auth: AuthContext? = nil) async throws -> GetLocationsResponse { | ||
try await query( | ||
"social.grain.unspecced.getLocations", | ||
"social.grain.gallery.getLocations", | ||
params: pins ? ["pins": "true"] : [:], | ||
auth: auth, | ||
as: GetLocationsResponse.self | ||
| @@ -66,6 +66,6 @@ extension XRPCClient { | ||
| ||
/// The top thirty cameras by photo count, each with a few thumbnails. | ||
func getCameras(auth: AuthContext? = nil) async throws -> GetCamerasResponse { | ||
try await query("social.grain.unspecced.getCameras", auth: auth, as: GetCamerasResponse.self) | ||
try await query("social.grain.gallery.getCameras", auth: auth, as: GetCamerasResponse.self) | ||
} | ||
} | ||
Grain/API/Endpoints/FeedEndpoints.swift+149−31
| @@ -21,7 +21,11 @@ struct GetPreferencesResponse: Codable, Sendable { | ||
let preferences: UserPreferences | ||
} | ||
| ||
struct NotifPref: Codable, Sendable { | ||
struct PutPreferencesInput: Encodable, Sendable { | ||
let preferences: UserPreferences | ||
} | ||
| ||
struct NotifPref: Codable, Sendable, Equatable { | ||
var push: Bool | ||
var inApp: Bool | ||
var from: String // "all" or "follows" | ||
| @@ -29,7 +33,7 @@ struct NotifPref: Codable, Sendable { | ||
static let `default` = NotifPref(push: true, inApp: true, from: "all") | ||
} | ||
| ||
struct NotificationPrefs: Codable, Sendable { | ||
struct NotificationPrefs: Codable, Sendable, Equatable { | ||
var favorites: NotifPref? | ||
var follows: NotifPref? | ||
var comments: NotifPref? | ||
| @@ -43,11 +47,121 @@ struct NotificationPrefs: Codable, Sendable { | ||
) | ||
} | ||
| ||
struct UserPreferences: Codable, Sendable { | ||
/// The viewer's grain preferences, `social.grain.actor.defs#preferences`: | ||
/// on the wire an array holding each kind at most once, by its `$type`. | ||
/// `theme` is the web's; the app keeps it so writing the set back leaves it | ||
/// as it was. | ||
struct UserPreferences: Codable, Sendable, Equatable { | ||
var pinnedFeeds: [PinnedFeed]? | ||
var includeExif: Bool? | ||
var includeLocation: Bool? | ||
var notificationPrefs: NotificationPrefs? | ||
var theme: String? | ||
| ||
static let pinnedFeedsType = "social.grain.actor.defs#pinnedFeedsPref" | ||
static let photoMetadataType = "social.grain.actor.defs#photoMetadataPref" | ||
static let notificationsType = "social.grain.actor.defs#notificationsPref" | ||
static let themeType = "social.grain.actor.defs#themePref" | ||
| ||
init( | ||
pinnedFeeds: [PinnedFeed]? = nil, | ||
includeExif: Bool? = nil, | ||
includeLocation: Bool? = nil, | ||
notificationPrefs: NotificationPrefs? = nil, | ||
theme: String? = nil | ||
) { | ||
self.pinnedFeeds = pinnedFeeds | ||
self.includeExif = includeExif | ||
self.includeLocation = includeLocation | ||
self.notificationPrefs = notificationPrefs | ||
self.theme = theme | ||
} | ||
| ||
private enum Keys: String, CodingKey { | ||
case type = "$type" | ||
case items, includeExif, includeLocation, theme | ||
} | ||
| ||
/// One item of the array, whatever its kind, with the fields each kind | ||
/// has. A kind the app does not know decodes to nothing. | ||
private struct Item: Decodable { | ||
let type: String? | ||
let items: [PinnedFeed]? | ||
let includeExif: Bool? | ||
let includeLocation: Bool? | ||
let theme: String? | ||
let favorites: NotifPref? | ||
let follows: NotifPref? | ||
let comments: NotifPref? | ||
let mentions: NotifPref? | ||
| ||
private enum CodingKeys: String, CodingKey { | ||
case type = "$type" | ||
case items, includeExif, includeLocation, theme, favorites, follows, comments, mentions | ||
} | ||
} | ||
| ||
init(from decoder: Decoder) throws { | ||
self.init() | ||
for item in try [Item](from: decoder) { | ||
switch item.type { | ||
case Self.pinnedFeedsType: | ||
pinnedFeeds = item.items | ||
case Self.photoMetadataType: | ||
includeExif = item.includeExif | ||
includeLocation = item.includeLocation | ||
case Self.notificationsType: | ||
notificationPrefs = NotificationPrefs( | ||
favorites: item.favorites, | ||
follows: item.follows, | ||
comments: item.comments, | ||
mentions: item.mentions | ||
) | ||
case Self.themeType: | ||
theme = item.theme | ||
default: | ||
break | ||
} | ||
} | ||
} | ||
| ||
func encode(to encoder: Encoder) throws { | ||
var list = encoder.unkeyedContainer() | ||
if let pinnedFeeds { | ||
var item = list.nestedContainer(keyedBy: Keys.self) | ||
try item.encode(Self.pinnedFeedsType, forKey: .type) | ||
try item.encode(pinnedFeeds, forKey: .items) | ||
} | ||
if includeExif != nil || includeLocation != nil { | ||
var item = list.nestedContainer(keyedBy: Keys.self) | ||
try item.encode(Self.photoMetadataType, forKey: .type) | ||
try item.encodeIfPresent(includeExif, forKey: .includeExif) | ||
try item.encodeIfPresent(includeLocation, forKey: .includeLocation) | ||
} | ||
if let theme { | ||
var item = list.nestedContainer(keyedBy: Keys.self) | ||
try item.encode(Self.themeType, forKey: .type) | ||
try item.encode(theme, forKey: .theme) | ||
} | ||
if let notificationPrefs { | ||
try list.encode(TypedNotificationPrefs(prefs: notificationPrefs)) | ||
} | ||
} | ||
} | ||
| ||
/// A `notificationsPref`: its kinds beside its `$type`. | ||
private struct TypedNotificationPrefs: Encodable { | ||
let prefs: NotificationPrefs | ||
| ||
private enum Keys: String, CodingKey { | ||
case type = "$type" | ||
} | ||
| ||
func encode(to encoder: Encoder) throws { | ||
var container = encoder.container(keyedBy: Keys.self) | ||
try container.encode(UserPreferences.notificationsType, forKey: .type) | ||
try prefs.encode(to: encoder) | ||
} | ||
} | ||
| ||
struct PinnedFeed: Codable, Sendable, Identifiable, Hashable { | ||
| @@ -62,7 +176,7 @@ struct PinnedFeed: Codable, Sendable, Identifiable, Hashable { | ||
PinnedFeed(id: "foryou", label: "For you", type: "feed", path: "/feeds/for-you"), | ||
] | ||
| ||
/// The feed name parameter for the API (e.g. "recent", "following", "camera", "location", "hashtag") | ||
/// The name of the feed it opens (e.g. "recent", "following", "camera", "location", "hashtag") | ||
var feedName: String { | ||
switch type { | ||
case "camera": "camera" | ||
| @@ -103,7 +217,7 @@ extension XRPCClient { | ||
tag: String? = nil, | ||
auth: AuthContext? = nil | ||
) async throws -> GetFeedResponse { | ||
var params = ["feed": feed, "limit": String(limit)] | ||
var params = ["limit": String(limit)] | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
| @@ -122,15 +236,35 @@ extension XRPCClient { | ||
if let tag { | ||
params["tag"] = tag | ||
} | ||
return try await query("dev.hatk.getFeed", params: params, auth: auth, as: GetFeedResponse.self) | ||
params["feed"] = Self.feedURI(feed) | ||
return try await query("social.grain.feed.getFeed", params: params, auth: auth, as: GetFeedResponse.self) | ||
} | ||
| ||
/// The URI grain-serve names its feed `name` by, as | ||
/// `social.grain.feed.describeFeeds` lists it. | ||
static func feedURI(_ name: String) -> String { | ||
"at://\(AuthManager.appviewDID)/social.grain.feed.generator/\(name)" | ||
} | ||
| ||
func getPreferences(auth: AuthContext? = nil) async throws -> GetPreferencesResponse { | ||
try await query("dev.hatk.getPreferences", auth: auth, as: GetPreferencesResponse.self) | ||
try await query("social.grain.actor.getPreferences", auth: auth, as: GetPreferencesResponse.self) | ||
} | ||
| ||
/// Replace the viewer's whole set of preferences. | ||
func putPreferences(_ preferences: UserPreferences, auth: AuthContext? = nil) async throws { | ||
try await procedure("social.grain.actor.putPreferences", input: PutPreferencesInput(preferences: preferences), auth: auth) | ||
} | ||
| ||
/// Change one kind of preference: the set is read, changed and written | ||
/// back whole, so the kinds left alone stay as they were. | ||
func updatePreferences(auth: AuthContext? = nil, _ change: (inout UserPreferences) -> Void) async throws { | ||
var preferences = try await getPreferences(auth: auth).preferences | ||
change(&preferences) | ||
try await putPreferences(preferences, auth: auth) | ||
} | ||
| ||
func getGallery(uri: String, auth: AuthContext? = nil) async throws -> GetGalleryResponse { | ||
try await query("social.grain.unspecced.getGallery", params: ["gallery": uri], auth: auth, as: GetGalleryResponse.self) | ||
try await query("social.grain.gallery.getGallery", params: ["gallery": uri], auth: auth, as: GetGalleryResponse.self) | ||
} | ||
| ||
func getCommentThread( | ||
| @@ -143,39 +277,23 @@ extension XRPCClient { | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
return try await query("social.grain.unspecced.getCommentThread", params: params, auth: auth, as: GetCommentThreadResponse.self) | ||
return try await query("social.grain.comment.getThread", params: params, auth: auth, as: GetCommentThreadResponse.self) | ||
} | ||
| ||
func putPinnedFeeds(_ feeds: [PinnedFeed], auth: AuthContext? = nil) async throws { | ||
struct Input: Encodable { | ||
let key: String | ||
let value: [PinnedFeed] | ||
} | ||
try await procedure("dev.hatk.putPreference", input: Input(key: "pinnedFeeds", value: feeds), auth: auth) | ||
try await updatePreferences(auth: auth) { $0.pinnedFeeds = feeds } | ||
} | ||
| ||
func putIncludeExif(_ value: Bool, auth: AuthContext? = nil) async throws { | ||
struct Input: Encodable { | ||
let key: String | ||
let value: Bool | ||
} | ||
try await procedure("dev.hatk.putPreference", input: Input(key: "includeExif", value: value), auth: auth) | ||
try await updatePreferences(auth: auth) { $0.includeExif = value } | ||
} | ||
| ||
func putIncludeLocation(_ value: Bool, auth: AuthContext? = nil) async throws { | ||
struct Input: Encodable { | ||
let key: String | ||
let value: Bool | ||
} | ||
try await procedure("dev.hatk.putPreference", input: Input(key: "includeLocation", value: value), auth: auth) | ||
try await updatePreferences(auth: auth) { $0.includeLocation = value } | ||
} | ||
| ||
func putNotificationPrefs(_ prefs: NotificationPrefs, auth: AuthContext? = nil) async throws { | ||
struct Input: Encodable { | ||
let key: String | ||
let value: NotificationPrefs | ||
} | ||
try await procedure("dev.hatk.putPreference", input: Input(key: "notificationPrefs", value: prefs), auth: auth) | ||
try await updatePreferences(auth: auth) { $0.notificationPrefs = prefs } | ||
} | ||
| ||
func getActorFavorites( | ||
| @@ -188,7 +306,7 @@ extension XRPCClient { | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
return try await query("social.grain.unspecced.getActorFavorites", params: params, auth: auth, as: GetFeedResponse.self) | ||
return try await query("social.grain.actor.getFavorites", params: params, auth: auth, as: GetFeedResponse.self) | ||
} | ||
| ||
func searchGalleries( | ||
| @@ -202,6 +320,6 @@ extension XRPCClient { | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
return try await query("social.grain.unspecced.searchGalleries", params: params, auth: auth, as: SearchGalleriesResponse.self) | ||
return try await query("social.grain.gallery.searchGalleries", params: params, auth: auth, as: SearchGalleriesResponse.self) | ||
} | ||
} | ||
Grain/API/Endpoints/ModerationEndpoints.swift+79−20
| @@ -1,6 +1,8 @@ | ||
import Foundation | ||
| ||
struct LabelDefinition: Codable, Sendable, Identifiable { | ||
/// A label grain's labeler gives out, as | ||
/// `com.atproto.label.defs#labelValueDefinition` has one. | ||
struct LabelDefinition: Codable, Sendable, Identifiable, Equatable { | ||
let identifier: String | ||
var locales: [LabelLocale]? | ||
var blurs: String? | ||
| @@ -15,18 +17,73 @@ struct LabelDefinition: Codable, Sendable, Identifiable { | ||
} | ||
} | ||
| ||
struct LabelLocale: Codable, Sendable { | ||
struct LabelLocale: Codable, Sendable, Equatable { | ||
let name: String | ||
var description: String? | ||
} | ||
| ||
extension LabelDefinition { | ||
private static func grain(_ identifier: String, _ blurs: String, _ defaultSetting: String, _ name: String, _ description: String) -> LabelDefinition { | ||
LabelDefinition(identifier: identifier, locales: [LabelLocale(name: name, description: description)], blurs: blurs, defaultSetting: defaultSetting) | ||
} | ||
| ||
/// The labels grain gives out, as grain-serve defines them | ||
/// (`grain::labels::DEFINITIONS`), in its order: the reasons a report | ||
/// names and the warnings a gallery carries. The app reads them from | ||
/// `social.grain.moderation.describeLabels`; this copy is what it uses | ||
/// until that answers, and when it cannot. | ||
static let all: [LabelDefinition] = [ | ||
grain("copyright", "content", "hide", "Copyright Violation", "Content that infringes on copyright or intellectual property."), | ||
grain("csae", "content", "hide", "Child Safety", "Sexual content involving a minor, or the exploitation, grooming or endangerment of a child."), | ||
grain("gore", "media", "hide", "Graphic Media", "Graphic or violent imagery."), | ||
grain("harassment", "none", "warn", "Harassment", "Targeted harassment, bullying, or hate speech."), | ||
grain("misleading", "none", "warn", "Misleading", "Impersonation, misinformation, or deceptive content."), | ||
grain("nudity", "media", "warn", "Nudity", "Content containing nudity."), | ||
grain("other", "none", "ignore", "Other", "Content that does not fit other report categories."), | ||
grain("sexual", "media", "warn", "Sexual Content", "Unwanted or mislabeled sexual content."), | ||
grain("spam", "content", "hide", "Spam", "Unwanted or repetitive promotional content."), | ||
] | ||
| ||
/// The `reasonType` a report for each label names: the one grain-serve | ||
/// files under that label (`grain::labels::label_for`). atproto's where | ||
/// it has one, Ozone's for graphic media and child safety, grain's own | ||
/// for the rest. | ||
static let reportReasons: [String: String] = [ | ||
"copyright": "social.grain.moderation.defs#reasonCopyright", | ||
"csae": "tools.ozone.report.defs#reasonChildSafetyOther", | ||
"gore": "tools.ozone.report.defs#reasonViolenceGraphicContent", | ||
"harassment": "com.atproto.moderation.defs#reasonRude", | ||
"misleading": "com.atproto.moderation.defs#reasonMisleading", | ||
"nudity": "social.grain.moderation.defs#reasonNudity", | ||
"other": "com.atproto.moderation.defs#reasonOther", | ||
"sexual": "com.atproto.moderation.defs#reasonSexual", | ||
"spam": "com.atproto.moderation.defs#reasonSpam", | ||
] | ||
| ||
/// The `reasonType` a report filed under `label` names. | ||
static func reasonType(for label: String) -> String { | ||
reportReasons[label] ?? reportReasons["other"]! | ||
} | ||
} | ||
| ||
/// `social.grain.moderation.describeLabels`' answer: the labels grain-serve | ||
/// gives out, as a Bluesky labeler's service record has them. | ||
struct DescribeLabelsResponse: Codable, Sendable { | ||
let definitions: [LabelDefinition]? | ||
struct Policies: Codable, Sendable { | ||
var labelValues: [String]? | ||
var labelValueDefinitions: [LabelDefinition]? | ||
} | ||
| ||
/// The labeler, as its labels name it in `src`. | ||
var did: String? | ||
let policies: Policies | ||
} | ||
| ||
/// `com.atproto.moderation.createReport`'s input. | ||
struct CreateReportInput: Codable, Sendable { | ||
let subject: StrongRef | ||
let label: String | ||
let reasonType: String | ||
var reason: String? | ||
let subject: StrongRef | ||
} | ||
| ||
struct StrongRef: Codable, Sendable { | ||
| @@ -41,10 +98,6 @@ struct StrongRef: Codable, Sendable { | ||
} | ||
} | ||
| ||
struct CreateReportResponse: Codable, Sendable { | ||
let id: Int | ||
} | ||
| ||
// MARK: - Block / Mute types | ||
| ||
struct MuteActorInput: Codable, Sendable { | ||
| @@ -85,18 +138,24 @@ struct MuteListResponse: Codable, Sendable { | ||
} | ||
| ||
extension XRPCClient { | ||
func describeLabels(auth: AuthContext? = nil) async throws -> [LabelDefinition] { | ||
let response = try await query("dev.hatk.describeLabels", auth: auth, as: DescribeLabelsResponse.self) | ||
return response.definitions ?? [] | ||
} | ||
| ||
/// Report a record to grain, filed under `label`. The call goes through | ||
/// the PDS to grain's AppView, which files grain's reports, rather than | ||
/// to the PDS's own moderation service. | ||
func createReport(subjectUri: String, subjectCid: String, label: String, reason: String? = nil, auth: AuthContext? = nil) async throws { | ||
let input = CreateReportInput( | ||
subject: StrongRef(type: "com.atproto.repo.strongRef", uri: subjectUri, cid: subjectCid), | ||
label: label, | ||
reason: reason | ||
reasonType: LabelDefinition.reasonType(for: label), | ||
reason: reason, | ||
subject: StrongRef(type: "com.atproto.repo.strongRef", uri: subjectUri, cid: subjectCid) | ||
) | ||
try await procedure("dev.hatk.createReport", input: input, auth: auth) | ||
try await procedure(Self.createReportMethod, input: input, auth: auth) | ||
} | ||
| ||
static let createReportMethod = "com.atproto.moderation.createReport" | ||
| ||
/// The labels grain-serve gives out and what each means. | ||
func describeLabels(auth: AuthContext? = nil) async throws -> [LabelDefinition] { | ||
let response = try await query("social.grain.moderation.describeLabels", auth: auth, as: DescribeLabelsResponse.self) | ||
return response.policies.labelValueDefinitions ?? [] | ||
} | ||
| ||
// MARK: - Blocks | ||
| @@ -117,7 +176,7 @@ extension XRPCClient { | ||
} | ||
| ||
func getBlocks(auth: AuthContext) async throws -> BlockListResponse { | ||
try await query("social.grain.unspecced.getBlocks", auth: auth, as: BlockListResponse.self) | ||
try await query("social.grain.graph.getBlocks", auth: auth, as: BlockListResponse.self) | ||
} | ||
| ||
// MARK: - Mutes | ||
| @@ -131,6 +190,6 @@ extension XRPCClient { | ||
} | ||
| ||
func getMutes(auth: AuthContext) async throws -> MuteListResponse { | ||
try await query("social.grain.unspecced.getMutes", auth: auth, as: MuteListResponse.self) | ||
try await query("social.grain.graph.getMutes", auth: auth, as: MuteListResponse.self) | ||
} | ||
} | ||
Grain/API/Endpoints/NotificationEndpoints.swift+17−11
| @@ -3,32 +3,38 @@ import Foundation | ||
struct GetNotificationsResponse: Codable, Sendable { | ||
let notifications: [GrainNotification] | ||
var cursor: String? | ||
var unseenCount: Int? | ||
/// When the viewer last marked their notifications seen, if they have. | ||
var seenAt: String? | ||
} | ||
| ||
struct GetUnreadCountResponse: Codable, Sendable { | ||
let count: Int | ||
} | ||
| ||
extension XRPCClient { | ||
/// Mark every notification up to now seen. | ||
func markNotificationsSeen(auth: AuthContext? = nil) async throws { | ||
struct PutPreferenceInput: Encodable { | ||
let key: String | ||
let value: String | ||
struct UpdateSeenInput: Encodable { | ||
let seenAt: String | ||
} | ||
let input = PutPreferenceInput(key: "lastSeenNotifications", value: DateFormatting.nowISO()) | ||
try await procedure("dev.hatk.putPreference", input: input, auth: auth) | ||
let input = UpdateSeenInput(seenAt: DateFormatting.nowISO()) | ||
try await procedure("social.grain.notification.updateSeen", input: input, auth: auth) | ||
} | ||
| ||
func getNotifications( | ||
limit: Int = 100, | ||
cursor: String? = nil, | ||
countOnly: Bool = false, | ||
auth: AuthContext? = nil | ||
) async throws -> GetNotificationsResponse { | ||
var params = ["limit": String(limit)] | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
if countOnly { | ||
params["countOnly"] = "true" | ||
} | ||
return try await query("social.grain.unspecced.getNotifications", params: params, auth: auth, as: GetNotificationsResponse.self) | ||
return try await query("social.grain.notification.listNotifications", params: params, auth: auth, as: GetNotificationsResponse.self) | ||
} | ||
| ||
/// How many notifications have come in since they were last seen. | ||
func getUnreadCount(auth: AuthContext? = nil) async throws -> Int { | ||
try await query("social.grain.notification.getUnreadCount", auth: auth, as: GetUnreadCountResponse.self).count | ||
} | ||
} | ||
Grain/API/Endpoints/ProfileEndpoints.swift+13−25
| @@ -95,53 +95,41 @@ struct ProfileSearchResult: Codable, Sendable, Identifiable { | ||
// MARK: - Convenience Extensions | ||
| ||
extension XRPCClient { | ||
func getActorProfile(actor: String, viewer: String? = nil, auth: AuthContext? = nil) async throws -> GrainProfileDetailed { | ||
var params = ["actor": actor] | ||
if let viewer { | ||
params["viewer"] = viewer | ||
} | ||
return try await query("social.grain.unspecced.getActorProfile", params: params, auth: auth, as: GrainProfileDetailed.self) | ||
func getActorProfile(actor: String, auth: AuthContext? = nil) async throws -> GrainProfileDetailed { | ||
try await query("social.grain.actor.getProfile", params: ["actor": actor], auth: auth, as: GrainProfileDetailed.self) | ||
} | ||
| ||
func getFollowers(actor: String, viewer: String? = nil, limit: Int = 50, cursor: String? = nil, auth: AuthContext? = nil) async throws -> GetFollowersResponse { | ||
func getFollowers(actor: String, limit: Int = 50, cursor: String? = nil, auth: AuthContext? = nil) async throws -> GetFollowersResponse { | ||
var params = ["actor": actor, "limit": String(limit)] | ||
if let viewer { | ||
params["viewer"] = viewer | ||
} | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
return try await query("social.grain.unspecced.getFollowers", params: params, auth: auth, as: GetFollowersResponse.self) | ||
return try await query("social.grain.graph.getFollowers", params: params, auth: auth, as: GetFollowersResponse.self) | ||
} | ||
| ||
func getFollowing(actor: String, viewer: String? = nil, limit: Int = 50, cursor: String? = nil, auth: AuthContext? = nil) async throws -> GetFollowingResponse { | ||
func getFollowing(actor: String, limit: Int = 50, cursor: String? = nil, auth: AuthContext? = nil) async throws -> GetFollowingResponse { | ||
var params = ["actor": actor, "limit": String(limit)] | ||
if let viewer { | ||
params["viewer"] = viewer | ||
} | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
return try await query("social.grain.unspecced.getFollowing", params: params, auth: auth, as: GetFollowingResponse.self) | ||
return try await query("social.grain.graph.getFollowing", params: params, auth: auth, as: GetFollowingResponse.self) | ||
} | ||
| ||
func getKnownFollowers(actor: String, viewer: String, auth: AuthContext? = nil) async throws -> GetKnownFollowersResponse { | ||
try await query("social.grain.unspecced.getKnownFollowers", params: ["actor": actor, "viewer": viewer], auth: auth, as: GetKnownFollowersResponse.self) | ||
/// The accounts the signed-in viewer follows that follow `actor`. | ||
func getKnownFollowers(actor: String, auth: AuthContext? = nil) async throws -> GetKnownFollowersResponse { | ||
try await query("social.grain.graph.getKnownFollowers", params: ["actor": actor], auth: auth, as: GetKnownFollowersResponse.self) | ||
} | ||
| ||
func getGalleryFavorites(gallery: String, viewer: String? = nil, limit: Int = 50, cursor: String? = nil, auth: AuthContext? = nil) async throws -> GetGalleryFavoritesResponse { | ||
func getGalleryFavorites(gallery: String, limit: Int = 50, cursor: String? = nil, auth: AuthContext? = nil) async throws -> GetGalleryFavoritesResponse { | ||
var params = ["gallery": gallery, "limit": String(limit)] | ||
if let viewer { | ||
params["viewer"] = viewer | ||
} | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
return try await query("social.grain.unspecced.getGalleryFavorites", params: params, auth: auth, as: GetGalleryFavoritesResponse.self) | ||
return try await query("social.grain.gallery.getFavorites", params: params, auth: auth, as: GetGalleryFavoritesResponse.self) | ||
} | ||
| ||
func getSuggestedFollows(actor: String, limit: Int = 10, auth: AuthContext? = nil) async throws -> GetSuggestedFollowsResponse { | ||
try await query("social.grain.unspecced.getSuggestedFollows", params: ["actor": actor, "limit": String(limit)], auth: auth, as: GetSuggestedFollowsResponse.self) | ||
try await query("social.grain.actor.getSuggestedFollows", params: ["actor": actor, "limit": String(limit)], auth: auth, as: GetSuggestedFollowsResponse.self) | ||
} | ||
| ||
func searchProfiles(query queryString: String, limit: Int = 30, cursor: String? = nil, auth: AuthContext? = nil) async throws -> SearchProfilesResponse { | ||
| @@ -149,6 +137,6 @@ extension XRPCClient { | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
return try await query("social.grain.unspecced.searchProfiles", params: params, auth: auth, as: SearchProfilesResponse.self) | ||
return try await query("social.grain.actor.searchActors", params: params, auth: auth, as: SearchProfilesResponse.self) | ||
} | ||
} | ||
Grain/API/Endpoints/RecordEndpoints.swift+131−19
| @@ -6,6 +6,7 @@ private let logger = Logger(subsystem: "social.grain.grain", category: "Records" | ||
struct CreateRecordInput: Codable, Sendable { | ||
let collection: String | ||
let repo: String | ||
var rkey: String? | ||
let record: AnyCodable | ||
} | ||
| ||
| @@ -19,6 +20,8 @@ struct PutRecordInput: Codable, Sendable { | ||
let rkey: String | ||
let record: AnyCodable | ||
let repo: String | ||
/// The CID the record must still have for the put to land. | ||
var swapRecord: String? | ||
} | ||
| ||
struct DeleteRecordInput: Codable, Sendable { | ||
| @@ -31,14 +34,11 @@ struct IndexRecordsInput: Codable, Sendable { | ||
let uris: [String] | ||
} | ||
| ||
struct DeleteGalleryInput: Codable, Sendable { | ||
let rkey: String | ||
} | ||
| ||
/// `com.atproto.repo.getRecord`'s answer. | ||
struct GetRecordResponse: Codable, Sendable { | ||
var uri: String? | ||
var cid: String? | ||
var record: AnyCodable? | ||
var value: AnyCodable? | ||
} | ||
| ||
/// One write inside a `com.atproto.repo.applyWrites` batch. | ||
| @@ -86,6 +86,31 @@ struct ApplyWritesResponse: Codable, Sendable { | ||
var results: [ApplyWriteResult]? | ||
} | ||
| ||
/// A page of `com.atproto.repo.listRecords`. | ||
struct ListRecordsResponse: Codable, Sendable { | ||
struct Record: Codable, Sendable { | ||
let uri: String | ||
var cid: String? | ||
var value: AnyCodable? | ||
} | ||
| ||
var records: [Record] | ||
var cursor: String? | ||
} | ||
| ||
struct DescribeRepoResponse: Codable, Sendable { | ||
var collections: [String]? | ||
} | ||
| ||
struct DeleteAccountInput: Codable, Sendable { | ||
let did: String | ||
} | ||
| ||
struct DeleteAccountResponse: Codable, Sendable { | ||
var deleted: Int? | ||
var done: Bool? | ||
} | ||
| ||
/// Writes go to the account's PDS as `com.atproto.repo.*` calls, with the | ||
/// account's DID as the repo. Each one that lands is then handed to the | ||
/// AppView's `social.grain.sync.indexRecords`, so the next read sees it | ||
| @@ -94,14 +119,26 @@ extension XRPCClient { | ||
/// How many URIs one `indexRecords` call takes. | ||
static let indexBatchSize = 25 | ||
| ||
/// How many writes one `applyWrites` commit takes. | ||
static let writeBatchSize = 200 | ||
| ||
/// Read the record at `uri` from its repo, at the account's PDS. | ||
func getRecord(uri: String, auth: AuthContext? = nil) async throws -> GetRecordResponse { | ||
try await query("dev.hatk.getRecord", params: ["uri": uri], auth: auth, as: GetRecordResponse.self) | ||
let parts = uri.replacingOccurrences(of: "at://", with: "").split(separator: "/", maxSplits: 2).map(String.init) | ||
guard parts.count == 3 else { throw XRPCError.invalidURL } | ||
return try await query( | ||
"com.atproto.repo.getRecord", | ||
params: ["repo": parts[0], "collection": parts[1], "rkey": parts[2]], | ||
auth: auth, | ||
as: GetRecordResponse.self | ||
) | ||
} | ||
| ||
func createRecord(collection: String, repo: String, record: AnyCodable, auth: AuthContext? = nil) async throws -> CreateRecordResponse { | ||
/// Create a record, at `rkey` if one is given. | ||
func createRecord(collection: String, repo: String, rkey: String? = nil, record: AnyCodable, auth: AuthContext? = nil) async throws -> CreateRecordResponse { | ||
let response = try await procedure( | ||
"com.atproto.repo.createRecord", | ||
input: CreateRecordInput(collection: collection, repo: repo, record: record), | ||
input: CreateRecordInput(collection: collection, repo: repo, rkey: rkey, record: record), | ||
auth: auth, | ||
as: CreateRecordResponse.self | ||
) | ||
| @@ -110,12 +147,13 @@ extension XRPCClient { | ||
} | ||
| ||
/// Create or overwrite the record at `rkey`. `repo` defaults to the | ||
/// signed-in account's. | ||
func putRecord(collection: String, rkey: String, record: AnyCodable, repo: String? = nil, auth: AuthContext? = nil) async throws -> CreateRecordResponse { | ||
/// signed-in account's. With `swapRecord`, only if the record there | ||
/// still has that CID. | ||
func putRecord(collection: String, rkey: String, record: AnyCodable, repo: String? = nil, swapRecord: String? = nil, auth: AuthContext? = nil) async throws -> CreateRecordResponse { | ||
let repo = repo ?? auth?.did ?? "" | ||
let response = try await procedure( | ||
"com.atproto.repo.putRecord", | ||
input: PutRecordInput(collection: collection, rkey: rkey, record: record, repo: repo), | ||
input: PutRecordInput(collection: collection, rkey: rkey, record: record, repo: repo, swapRecord: swapRecord), | ||
auth: auth, | ||
as: CreateRecordResponse.self | ||
) | ||
| @@ -125,7 +163,8 @@ extension XRPCClient { | ||
| ||
/// Apply every write in a single atomic PDS commit. Either all the records | ||
/// land or none do: there is no partially-created gallery to clean up. | ||
func applyWrites(_ writes: [ApplyWrite], auth: AuthContext? = nil) async throws -> ApplyWritesResponse { | ||
/// The records are then indexed, unless `indexing` is false. | ||
func applyWrites(_ writes: [ApplyWrite], auth: AuthContext? = nil, indexing: Bool = true) async throws -> ApplyWritesResponse { | ||
let repo = auth?.did ?? "" | ||
let response = try await procedure( | ||
"com.atproto.repo.applyWrites", | ||
| @@ -133,7 +172,9 @@ extension XRPCClient { | ||
auth: auth, | ||
as: ApplyWritesResponse.self | ||
) | ||
await indexRecords(writes.map { Self.uri(repo, $0.collection, $0.rkey) }, auth: auth) | ||
if indexing { | ||
await indexRecords(writes.map { Self.uri(repo, $0.collection, $0.rkey) }, auth: auth) | ||
} | ||
return response | ||
} | ||
| ||
| @@ -168,13 +209,84 @@ extension XRPCClient { | ||
"at://\(repo)/\(collection)/\(rkey)" | ||
} | ||
| ||
func deleteGallery(rkey: String, auth: AuthContext? = nil) async throws { | ||
try await procedure("social.grain.unspecced.deleteGallery", input: DeleteGalleryInput(rkey: rkey), auth: auth) | ||
/// Every record of the account's in `collection`, from its PDS. | ||
func listAllRecords(collection: String, auth: AuthContext) async throws -> [ListRecordsResponse.Record] { | ||
var records: [ListRecordsResponse.Record] = [] | ||
var cursor: String? | ||
repeat { | ||
var params = ["repo": auth.did, "collection": collection, "limit": "100"] | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
let page = try await query("com.atproto.repo.listRecords", params: params, auth: auth, as: ListRecordsResponse.self) | ||
records += page.records | ||
cursor = page.records.isEmpty ? nil : page.cursor | ||
} while cursor != nil | ||
return records | ||
} | ||
| ||
func deleteAccount(auth: AuthContext? = nil) async throws { | ||
try await procedure("social.grain.unspecced.deleteAccount", input: EmptyInput(), auth: auth) | ||
/// Delete `uris` from the account's repo, `writeBatchSize` to a commit, | ||
/// in the order given, each commit indexed unless `indexing` is false. | ||
func deleteRecords(_ uris: [String], auth: AuthContext, indexing: Bool = true) async throws { | ||
let writes: [ApplyWrite] = uris.compactMap { uri in | ||
let parts = uri.split(separator: "/").map(String.init) | ||
guard parts.count >= 3 else { return nil } | ||
return .delete(collection: parts[parts.count - 2], rkey: parts[parts.count - 1]) | ||
} | ||
for start in stride(from: 0, to: writes.count, by: Self.writeBatchSize) { | ||
let batch = Array(writes[start ..< min(start + Self.writeBatchSize, writes.count)]) | ||
_ = try await applyWrites(batch, auth: auth, indexing: indexing) | ||
} | ||
} | ||
} | ||
| ||
private struct EmptyInput: Codable, Sendable {} | ||
/// Delete the account's gallery `rkey` and what goes with it, at its | ||
/// PDS, as grain-serve deletes one for the web: the exif of the photos | ||
/// it alone places, its items, those photos, the account's own | ||
/// favorites and comments on it, then the gallery. Each commit is then | ||
/// indexed. | ||
func deleteGallery(rkey: String, auth: AuthContext) async throws { | ||
let gallery = Self.uri(auth.did, "social.grain.gallery", rkey) | ||
let photoPrefix = "at://\(auth.did)/social.grain.photo/" | ||
func field(_ record: ListRecordsResponse.Record, _ name: String) -> String? { | ||
record.value?.dictValue?[name]?.stringValue | ||
} | ||
| ||
let items = try await listAllRecords(collection: "social.grain.gallery.item", auth: auth) | ||
let its = items.filter { field($0, "gallery") == gallery } | ||
let elsewhere = Set(items.filter { field($0, "gallery") != gallery }.compactMap { field($0, "item") }) | ||
let photos = Set(its.compactMap { field($0, "item") }.filter { $0.hasPrefix(photoPrefix) && !elsewhere.contains($0) }) | ||
| ||
let exif = try await listAllRecords(collection: "social.grain.photo.exif", auth: auth) | ||
.filter { field($0, "photo").map(photos.contains) ?? false } | ||
let favorites = try await listAllRecords(collection: "social.grain.favorite", auth: auth) | ||
.filter { field($0, "subject") == gallery } | ||
let comments = try await listAllRecords(collection: "social.grain.comment", auth: auth) | ||
.filter { field($0, "subject") == gallery } | ||
| ||
let going = exif.map(\.uri).sorted() | ||
+ its.map(\.uri).sorted() | ||
+ photos.sorted() | ||
+ favorites.map(\.uri).sorted() | ||
+ comments.map(\.uri).sorted() | ||
+ [gallery] | ||
try await deleteRecords(going, auth: auth) | ||
} | ||
| ||
/// Delete the account's grain data: every record in each of its | ||
/// `social.grain.*` collections, at its PDS, then ask grain-serve to | ||
/// forget it, which it does once the PDS lists none of them. The | ||
/// atproto account, and other apps' records in it, are untouched. | ||
func deleteAccount(auth: AuthContext) async throws { | ||
let repo = try await query("com.atproto.repo.describeRepo", params: ["repo": auth.did], auth: auth, as: DescribeRepoResponse.self) | ||
for collection in repo.collections ?? [] where collection.hasPrefix("social.grain.") { | ||
let uris = try await listAllRecords(collection: collection, auth: auth).map(\.uri) | ||
try await deleteRecords(uris, auth: auth, indexing: false) | ||
} | ||
try await procedure( | ||
"social.grain.server.deleteAccount", | ||
input: DeleteAccountInput(did: auth.did), | ||
auth: auth, | ||
as: DeleteAccountResponse.self | ||
) | ||
} | ||
} | ||
Grain/API/Endpoints/StoryEndpoints.swift+5−5
| @@ -23,11 +23,11 @@ struct MarkStoriesViewedInput: Codable, Sendable { | ||
| ||
extension XRPCClient { | ||
func getStories(actor: String, auth: AuthContext? = nil) async throws -> GetStoriesResponse { | ||
try await query("social.grain.unspecced.getStories", params: ["actor": actor], auth: auth, as: GetStoriesResponse.self) | ||
try await query("social.grain.story.getStories", params: ["actor": actor], auth: auth, as: GetStoriesResponse.self) | ||
} | ||
| ||
func getStory(uri: String, auth: AuthContext? = nil) async throws -> GetStoryResponse { | ||
try await query("social.grain.unspecced.getStory", params: ["story": uri], auth: auth, as: GetStoryResponse.self) | ||
try await query("social.grain.story.getStory", params: ["story": uri], auth: auth, as: GetStoryResponse.self) | ||
} | ||
| ||
func getStoryArchive(actor: String, limit: Int = 50, cursor: String? = nil, auth: AuthContext? = nil) async throws -> GetStoryArchiveResponse { | ||
| @@ -35,16 +35,16 @@ extension XRPCClient { | ||
if let cursor { | ||
params["cursor"] = cursor | ||
} | ||
return try await query("social.grain.unspecced.getStoryArchive", params: params, auth: auth, as: GetStoryArchiveResponse.self) | ||
return try await query("social.grain.story.getArchive", params: params, auth: auth, as: GetStoryArchiveResponse.self) | ||
} | ||
| ||
func getStoryAuthors(auth: AuthContext? = nil) async throws -> GetStoryAuthorsResponse { | ||
try await query("social.grain.unspecced.getStoryAuthors", auth: auth, as: GetStoryAuthorsResponse.self) | ||
try await query("social.grain.story.getAuthors", auth: auth, as: GetStoryAuthorsResponse.self) | ||
} | ||
| ||
/// Tell the appview these stories were watched, so the web and Android | ||
/// see them as watched too. At most 100 per call. | ||
func markStoriesViewed(uris: [String], auth: AuthContext) async throws { | ||
try await procedure("social.grain.unspecced.markStoriesViewed", input: MarkStoriesViewedInput(stories: uris), auth: auth) | ||
try await procedure("social.grain.story.markViewed", input: MarkStoriesViewedInput(stories: uris), auth: auth) | ||
} | ||
} | ||
Grain/API/AuthManager.swift+19−5
| @@ -31,23 +31,33 @@ final class AuthManager { | ||
/// The OAuth flow currently on screen, if any. At most one at a time. | ||
private var loginTask: Task<Void, Error>? | ||
| ||
#if PRODUCTION_API || !targetEnvironment(simulator) | ||
#if NEXT_API | ||
/// next.grain.social, the grain-serve deploy beside production. | ||
nonisolated static let serverURL = URL(string: "https://next.grain.social")! | ||
nonisolated static let signUpPDS = URL(string: "https://selfhosted.social")! | ||
nonisolated static let callbackScheme = "social.grain.next" | ||
#elseif PRODUCTION_API || !targetEnvironment(simulator) | ||
nonisolated static let serverURL = URL(string: "https://grain.social")! | ||
/// Where the sign-up button makes an account. | ||
nonisolated static let signUpPDS = URL(string: "https://selfhosted.social")! | ||
/// The server's host reversed, the private-use scheme atproto OAuth | ||
/// asks of a native client. Info.plist registers it as | ||
/// `GRAIN_OAUTH_SCHEME`, which project.yml and the justfile set to | ||
/// match. | ||
nonisolated static let callbackScheme = "social.grain" | ||
#else | ||
nonisolated static let serverURL = URL(string: "http://127.0.0.1:3000")! | ||
nonisolated static let signUpPDS = URL(string: "http://localhost:2583")! | ||
nonisolated static let callbackScheme = "social.grain" | ||
#endif | ||
| ||
/// The client metadata document grain-serve publishes for the apps. A PDS | ||
/// fetches it, so it must be https: a dev build signing in against a | ||
/// local server needs that server behind an https tunnel. | ||
nonisolated static let clientID = serverURL.appendingPathComponent("oauth/app-client-metadata.json").absoluteString | ||
/// The app's bundle id reversed into a URL scheme, with one slash, as | ||
/// atproto OAuth asks of a native client. | ||
nonisolated static let redirectURI = "social.grain:/oauth/callback" | ||
nonisolated static let callbackScheme = "social.grain" | ||
/// The callback scheme with one slash, as atproto OAuth asks of a | ||
/// native client. | ||
nonisolated static let redirectURI = "\(callbackScheme):/oauth/callback" | ||
| ||
/// The server's `did:web`, port and all (`:` as `%3A`). | ||
nonisolated static let appviewDID: String = { | ||
| @@ -228,6 +238,10 @@ final class AuthManager { | ||
let callback = try await presentAuthorization(pending.url) | ||
let signedIn = try await oauth.redeem(pending, callback: callback) | ||
store(signedIn) | ||
if let auth = await authContext() { | ||
let client = makeClient() | ||
Task.detached { await ProfileSeed.run(client: client, auth: auth) } | ||
} | ||
await fetchAndStoreAvatar() | ||
} | ||
| ||
Grain/API/PushManager.swift+17−62
| @@ -5,7 +5,7 @@ import UserNotifications | ||
| ||
private let logger = Logger(subsystem: "social.grain.grain", category: "Push") | ||
| ||
/// Manages push notification registration and token delivery to the server. | ||
/// Manages push notification registration and token delivery to grain-serve. | ||
@Observable | ||
@MainActor | ||
final class PushManager: NSObject { | ||
| @@ -67,19 +67,11 @@ final class PushManager: NSObject { | ||
currentToken = nil | ||
let client = XRPCClient(baseURL: AuthManager.serverURL) | ||
do { | ||
do { | ||
try await client.procedure( | ||
"social.grain.notification.unregisterPush", | ||
input: PushRegistrationInput(token: token), | ||
auth: auth | ||
) | ||
} catch let error where Self.isMethodMissing(error) { | ||
try await client.procedure( | ||
"dev.hatk.push.unregisterToken", | ||
input: UnregisterTokenInput(token: token), | ||
auth: auth | ||
) | ||
} | ||
try await client.procedure( | ||
"social.grain.notification.unregisterPush", | ||
input: PushDevice(token: token), | ||
auth: auth | ||
) | ||
logger.info("Push token unregistered from server") | ||
} catch { | ||
logger.error("Failed to unregister push token: \(error)") | ||
| @@ -103,68 +95,31 @@ final class PushManager: NSObject { | ||
| ||
let client = authManager.makeClient() | ||
do { | ||
do { | ||
try await client.procedure( | ||
"social.grain.notification.registerPush", | ||
input: PushRegistrationInput(token: token), | ||
auth: auth | ||
) | ||
} catch let error where Self.isMethodMissing(error) { | ||
try await client.procedure( | ||
"dev.hatk.push.registerToken", | ||
input: RegisterTokenInput(token: token, platform: "apns"), | ||
auth: auth | ||
) | ||
} | ||
try await client.procedure( | ||
"social.grain.notification.registerPush", | ||
input: PushDevice(token: token), | ||
auth: auth | ||
) | ||
logger.info("Push token registered with server") | ||
} catch { | ||
logger.error("Failed to register push token: \(error)") | ||
} | ||
} | ||
| ||
/// Whether the server answered that it does not serve the method at all, | ||
/// as a server without `social.grain.notification.registerPush` does. Only | ||
/// then is the older `registerToken` call tried; any other failure, such as | ||
/// a 401 or a rejected input, is the answer. | ||
nonisolated static func isMethodMissing(_ error: Error) -> Bool { | ||
guard case let XRPCError.httpError(statusCode, body) = error else { return false } | ||
if statusCode == 404 || statusCode == 501 { | ||
return true | ||
} | ||
guard let body, | ||
let object = try? JSONSerialization.jsonObject(with: body) as? [String: Any], | ||
let name = object["error"] as? String | ||
else { return false } | ||
return name == "MethodNotImplemented" || name == "MethodNotFound" | ||
} | ||
} | ||
| ||
/// The input of `social.grain.notification.registerPush` and `unregisterPush`, | ||
/// shaped as Bluesky's `app.bsky.notification.registerPush`. | ||
struct PushRegistrationInput: Encodable, Equatable { | ||
/// The service that sends the pushes. | ||
static let grainServiceDid = "did:web:grain.social" | ||
/// The bundle id APNs pushes are addressed to. | ||
static let grainAppId = "social.grain.grain" | ||
| ||
/// A device, as `social.grain.notification.registerPush` and | ||
/// `unregisterPush` name it: the service that pushes to it (grain-serve), | ||
/// its APNs token, and the app it runs. | ||
struct PushDevice: Encodable { | ||
let serviceDid: String | ||
let token: String | ||
let platform: String | ||
let appId: String | ||
| ||
init(token: String) { | ||
serviceDid = Self.grainServiceDid | ||
serviceDid = AuthManager.appviewDID | ||
self.token = token | ||
platform = "ios" | ||
appId = Self.grainAppId | ||
appId = Bundle.main.bundleIdentifier ?? "social.grain.grain" | ||
} | ||
} | ||
| ||
private struct RegisterTokenInput: Encodable { | ||
let token: String | ||
let platform: String | ||
} | ||
| ||
private struct UnregisterTokenInput: Encodable { | ||
let token: String | ||
} | ||
Grain/API/XRPCClient.swift+10−5
| @@ -29,9 +29,9 @@ enum XRPCError: Error, LocalizedError { | ||
/// | ||
/// A call without an auth context goes to `baseURL`, Grain's AppView, as is. | ||
/// A call with one goes to the account's PDS, with a DPoP-bound token and a | ||
/// proof per request; every method outside `com.atproto.*` also carries an | ||
/// `atproto-proxy` header, so the PDS forwards it to the AppView with a | ||
/// service token, as Bluesky's apps reach their AppView. | ||
/// proof per request; every method outside `com.atproto.*`, and a report, | ||
/// also carries an `atproto-proxy` header, so the PDS forwards it to the | ||
/// AppView with a service token, as Bluesky's apps reach their AppView. | ||
final class XRPCClient: Sendable { | ||
let baseURL: URL | ||
private let session: URLSession | ||
| @@ -106,9 +106,14 @@ final class XRPCClient: Sendable { | ||
return try decode(UploadBlobResponse.self, from: response) | ||
} | ||
| ||
/// The `atproto-proxy` value a signed-in call to `nsid` carries, if any. | ||
/// The `atproto-proxy` value a signed-in call to `nsid` carries, if any: | ||
/// grain's AppView for every method outside `com.atproto.*`, and for a | ||
/// report, which grain files rather than the PDS's moderation service. | ||
static func proxy(for nsid: String) -> String? { | ||
nsid.hasPrefix("com.atproto.") ? nil : AuthManager.appviewService | ||
if nsid == createReportMethod { | ||
return AuthManager.appviewService | ||
} | ||
return nsid.hasPrefix("com.atproto.") ? nil : AuthManager.appviewService | ||
} | ||
| ||
// MARK: - Private | ||