~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1

Calls reach grain-serve by its own method names

Merged chadtmiller.com asked to merge into main from grain.social/grain-ios:feature/rust-calls
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
Grain/Models/Views/NotificationModels.swift+5−1
Grain/Models/Views/ProfileModels.swift+6−2
Grain/Models/Views/StoryModels.swift+1−1
Grain/Services/GalleryService.swift+8−11
Grain/Services/ProfileSeed.swiftnew+99−0
Grain/ViewModels/LabelDefinitionsCache.swift+23−3
Grain/ViewModels/NotificationsViewModel.swift+8−4
Grain/ViewModels/ProfileDetailViewModel.swift+2−2
Grain/Views/Components/MentionAutocomplete.swift+1−1
Grain/Views/Components/ReportView.swift+16−24
Grain/Views/Profile/FollowListView.swift+8−8
Grain/Views/Settings/EditProfileView.swift+1−3
Grain/Views/LoginView.swift+1−1
Grain/Views/MainTabView.swift+1−1
Grain/GrainApp.swift+1−1
GrainTests/Helpers/Fixtures.swift+14−24
GrainTests/Helpers/MockURLProtocol.swift+3−3
GrainTests/CreateSheetRenderTests.swift+1−1
GrainTests/EmptyAndFailedStateRenderTests.swift+6−6
GrainTests/EndpointContractTests.swift+196−88
GrainTests/FeedPreferencesViewModelTests.swift+16−12
GrainTests/GalleryDeleteTests.swift+2−1
GrainTests/GalleryPublishTests.swift+2−2
GrainTests/GalleryServiceTests.swift+91−31
GrainTests/LabelDefinitionsCacheTests.swift+72−72
GrainTests/NetworkSideEffectTests.swift+21−162
GrainTests/NotificationsListRenderTests.swift+3−2
GrainTests/NotificationsViewModelTests.swift+81−8
GrainTests/PDSCallTests.swift+9−9
GrainTests/ProfileFavoritesAndModerationTests.swift+12−12
GrainTests/ProfileSeedTests.swiftnew+199−0
GrainTests/StoryViewSyncTests.swift+1−1
GrainTests/ViewerStateRenderTests.swift+2−2
GrainTests/XRPCClientTests.swift+21−21
DEVELOPMENT.md+1−0
justfile+17−0
project.yml+5−2