1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
import Foundation
/// 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?
var defaultSetting: String?
var id: String {
identifier
}
var displayName: String {
locales?.first?.name ?? identifier
}
}
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 {
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 reasonType: String
var reason: String?
let subject: StrongRef
}
struct StrongRef: Codable, Sendable {
let type: String
let uri: String
let cid: String
enum CodingKeys: String, CodingKey {
case type = "$type"
case uri
case cid
}
}
// MARK: - Block / Mute types
struct MuteActorInput: Codable, Sendable {
let actor: String
}
struct BlockItem: Codable, Sendable, Identifiable {
let did: String
var handle: String?
var displayName: String?
var avatar: String?
let blockUri: String
var id: String {
did
}
}
struct MuteItem: Codable, Sendable, Identifiable {
let did: String
var handle: String?
var displayName: String?
var avatar: String?
var id: String {
did
}
}
struct BlockListResponse: Codable, Sendable {
let items: [BlockItem]?
var cursor: String?
}
struct MuteListResponse: Codable, Sendable {
let items: [MuteItem]?
var cursor: String?
}
extension XRPCClient {
/// 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(
reasonType: LabelDefinition.reasonType(for: label),
reason: reason,
subject: StrongRef(type: "com.atproto.repo.strongRef", uri: subjectUri, cid: subjectCid)
)
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
@discardableResult
func blockActor(did: String, auth: AuthContext) async throws -> CreateRecordResponse {
let repo = TokenStorage.userDID ?? ""
let record = AnyCodable([
"subject": did,
"createdAt": DateFormatting.nowISO(),
])
return try await createRecord(collection: "social.grain.graph.block", repo: repo, record: record, auth: auth)
}
func unblockActor(blockUri: String, auth: AuthContext) async throws {
let rkey = blockUri.split(separator: "/").last.map(String.init) ?? ""
try await deleteRecord(collection: "social.grain.graph.block", rkey: rkey, auth: auth)
}
func getBlocks(auth: AuthContext) async throws -> BlockListResponse {
try await query("social.grain.graph.getBlocks", auth: auth, as: BlockListResponse.self)
}
// MARK: - Mutes
func muteActor(did: String, auth: AuthContext) async throws {
try await procedure("social.grain.graph.muteActor", input: MuteActorInput(actor: did), auth: auth)
}
func unmuteActor(did: String, auth: AuthContext) async throws {
try await procedure("social.grain.graph.unmuteActor", input: MuteActorInput(actor: did), auth: auth)
}
func getMutes(auth: AuthContext) async throws -> MuteListResponse {
try await query("social.grain.graph.getMutes", auth: auth, as: MuteListResponse.self)
}
}