krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: Hutch/Views/Settings/SettingsViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct MeProfileResponse: Decodable, Sendable {
6 let me: UserProfile
7}
8
9private struct UpdateUserResponse: Decodable, Sendable {
10 let updateUser: UpdatedUser
11}
12
13private struct UpdatedUser: Decodable, Sendable {
14 let username: String
15 let email: String
16 let url: String?
17 let location: String?
18 let bio: String?
19 let avatar: String?
20}
21
22private struct CreateSSHKeyResponse: Decodable, Sendable {
23 let createSSHKey: SSHKey
24}
25
26private struct DeleteSSHKeyResponse: Decodable, Sendable {
27 let deleteSSHKey: DeleteResult?
28}
29
30private struct CreatePGPKeyResponse: Decodable, Sendable {
31 let createPGPKey: PGPKey
32}
33
34private struct DeletePGPKeyResponse: Decodable, Sendable {
35 let deletePGPKey: DeleteResult?
36}
37
38private struct DeleteResult: Decodable, Sendable {
39 let id: Int?
40}
41
42private struct PATListResponse: Decodable, Sendable {
43 let personalAccessTokens: [PersonalAccessToken]
44}
45
46private struct AuditLogResponse: Decodable, Sendable {
47 let auditLog: AuditLogPage
48}
49
50private struct AuditLogPage: Decodable, Sendable {
51 let results: [AuditLogEntry]
52 let cursor: String?
53}
54
55// MARK: - View Model
56
57@Observable
58@MainActor
59final class SettingsViewModel {
60
61 private(set) var profile: UserProfile?
62 private(set) var sshKeys: [SSHKey] = []
63 private(set) var pgpKeys: [PGPKey] = []
64 private(set) var personalAccessTokens: [PersonalAccessToken] = []
65 private(set) var auditLog: [AuditLogEntry] = []
66 private(set) var isLoadingAuditLog = false
67 /// Kept apart from `error` so a failed audit fetch cannot bury a profile
68 /// save failure, and vice versa.
69 var auditLogError: String?
70
71 private(set) var isLoading = false
72 private(set) var isLoadingPATs = false
73 private(set) var isSavingProfile = false
74 private(set) var isUploadingAvatar = false
75 var error: String?
76
77 var isEditingProfile = false
78
79 // Add SSH key fields
80 var newSSHKey = ""
81 var isAddingSSHKey = false
82
83 // Add PGP key fields
84 var newPGPKey = ""
85 var isAddingPGPKey = false
86
87 private let client: SRHTClient
88
89 init(client: SRHTClient) {
90 self.client = client
91 }
92
93 // MARK: - Queries
94
95 private static let profileQuery = """
96 query me {
97 me {
98 username
99 canonicalName
100 email
101 url
102 location
103 bio
104 avatar
105 userType
106 sshKeys {
107 results { id comment created lastUsed }
108 cursor
109 }
110 pgpKeys {
111 results { id fingerprint created }
112 cursor
113 }
114 paymentStatus
115 subscription { status autorenew interval }
116 }
117 }
118 """
119
120 private static let updateUserMutation = """
121 mutation updateUser($input: UserInput!) {
122 updateUser(input: $input) {
123 username email url location bio avatar
124 }
125 }
126 """
127
128 private static let createSSHKeyMutation = """
129 mutation createSSHKey($key: String!) {
130 createSSHKey(key: $key) {
131 id comment created lastUsed
132 }
133 }
134 """
135
136 private static let deleteSSHKeyMutation = """
137 mutation deleteSSHKey($id: Int!) {
138 deleteSSHKey(id: $id) { id }
139 }
140 """
141
142 private static let createPGPKeyMutation = """
143 mutation createPGPKey($key: String!) {
144 createPGPKey(key: $key) {
145 id fingerprint created
146 }
147 }
148 """
149
150 private static let deletePGPKeyMutation = """
151 mutation deletePGPKey($id: Int!) {
152 deletePGPKey(id: $id) { id }
153 }
154 """
155
156 private static let auditLogQuery = """
157 query auditLog {
158 auditLog { results { id created ipAddress eventType details } }
159 }
160 """
161
162 private static let personalAccessTokensQuery = """
163 query personalAccessTokens {
164 personalAccessTokens { id issued expires comment grants }
165 }
166 """
167
168 // MARK: - Load Profile
169
170 func loadProfile() async {
171 guard !isLoading else { return }
172 isLoading = true
173 error = nil
174
175 do {
176 let result = try await client.execute(
177 service: .meta,
178 query: Self.profileQuery,
179 responseType: MeProfileResponse.self
180 )
181 profile = result.me
182 sshKeys = result.me.sshKeys.results
183 pgpKeys = result.me.pgpKeys.results
184 } catch {
185 self.error = error.userFacingMessage
186 }
187
188 isLoading = false
189 }
190
191 // MARK: - Update Profile
192
193 func saveProfile(email: String, url: String, location: String, bio: String) async {
194 guard !isSavingProfile else { return }
195 isSavingProfile = true
196 error = nil
197
198 do {
199 let input: [String: any Sendable] = [
200 "email": email,
201 "url": url.isEmpty ? nil as String? as any Sendable : url,
202 "location": location.isEmpty ? nil as String? as any Sendable : location,
203 "bio": bio.isEmpty ? nil as String? as any Sendable : bio
204 ]
205 let result = try await client.execute(
206 service: .meta,
207 query: Self.updateUserMutation,
208 variables: ["input": input],
209 responseType: UpdateUserResponse.self
210 )
211 let updated = result.updateUser
212 if let p = profile {
213 profile = UserProfile(
214 username: p.username,
215 canonicalName: p.canonicalName,
216 email: updated.email,
217 url: updated.url,
218 location: updated.location,
219 bio: updated.bio,
220 avatar: updated.avatar ?? p.avatar,
221 userType: p.userType,
222 sshKeys: p.sshKeys,
223 pgpKeys: p.pgpKeys,
224 paymentStatus: p.paymentStatus,
225 subscription: p.subscription
226 )
227 }
228 isEditingProfile = false
229 } catch {
230 self.error = error.userFacingMessage
231 }
232
233 isSavingProfile = false
234 }
235
236 // MARK: - Avatar
237
238 func uploadAvatar(jpegData: Data) async {
239 guard !isUploadingAvatar else { return }
240 isUploadingAvatar = true
241 error = nil
242
243 do {
244 // The input variable has avatar set to null; the actual file
245 // is sent as a separate multipart part per graphql-multipart-request-spec.
246 let input: [String: any Sendable] = ["avatar": nil as String? as any Sendable]
247 let result = try await client.executeMultipart(
248 service: .meta,
249 query: Self.updateUserMutation,
250 variables: ["input": input],
251 file: MultipartUploadFile(
252 variablePath: "input.avatar",
253 fileData: jpegData,
254 fileName: "avatar.jpg",
255 mimeType: "image/jpeg"
256 ),
257 responseType: UpdateUserResponse.self
258 )
259 let updated = result.updateUser
260 if let p = profile {
261 profile = UserProfile(
262 username: p.username,
263 canonicalName: p.canonicalName,
264 email: updated.email,
265 url: updated.url,
266 location: updated.location,
267 bio: updated.bio,
268 avatar: updated.avatar ?? p.avatar,
269 userType: p.userType,
270 sshKeys: p.sshKeys,
271 pgpKeys: p.pgpKeys,
272 paymentStatus: p.paymentStatus,
273 subscription: p.subscription
274 )
275 }
276 } catch {
277 self.error = error.userFacingMessage
278 }
279
280 isUploadingAvatar = false
281 }
282
283 func removeAvatar() async {
284 guard !isUploadingAvatar else { return }
285 isUploadingAvatar = true
286 error = nil
287
288 do {
289 let input: [String: any Sendable] = ["avatar": nil as String? as any Sendable]
290 let result = try await client.execute(
291 service: .meta,
292 query: Self.updateUserMutation,
293 variables: ["input": input],
294 responseType: UpdateUserResponse.self
295 )
296 let updated = result.updateUser
297 if let p = profile {
298 profile = UserProfile(
299 username: p.username,
300 canonicalName: p.canonicalName,
301 email: updated.email,
302 url: updated.url,
303 location: updated.location,
304 bio: updated.bio,
305 avatar: nil,
306 userType: p.userType,
307 sshKeys: p.sshKeys,
308 pgpKeys: p.pgpKeys,
309 paymentStatus: p.paymentStatus,
310 subscription: p.subscription
311 )
312 }
313 } catch {
314 self.error = error.userFacingMessage
315 }
316
317 isUploadingAvatar = false
318 }
319
320 // MARK: - SSH Keys
321
322 func addSSHKey() async {
323 let key = newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines)
324 guard !key.isEmpty else { return }
325 error = nil
326
327 do {
328 let result = try await client.execute(
329 service: .meta,
330 query: Self.createSSHKeyMutation,
331 variables: ["key": key],
332 responseType: CreateSSHKeyResponse.self
333 )
334 sshKeys.append(result.createSSHKey)
335 newSSHKey = ""
336 isAddingSSHKey = false
337 } catch {
338 self.error = error.userFacingMessage
339 }
340 }
341
342 func deleteSSHKey(_ key: SSHKey) async {
343 error = nil
344
345 do {
346 _ = try await client.execute(
347 service: .meta,
348 query: Self.deleteSSHKeyMutation,
349 variables: ["id": key.id],
350 responseType: DeleteSSHKeyResponse.self
351 )
352 sshKeys.removeAll { $0.id == key.id }
353 } catch {
354 self.error = error.userFacingMessage
355 }
356 }
357
358 // MARK: - PGP Keys
359
360 func addPGPKey() async {
361 let key = newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines)
362 guard !key.isEmpty else { return }
363 error = nil
364
365 do {
366 let result = try await client.execute(
367 service: .meta,
368 query: Self.createPGPKeyMutation,
369 variables: ["key": key],
370 responseType: CreatePGPKeyResponse.self
371 )
372 pgpKeys.append(result.createPGPKey)
373 newPGPKey = ""
374 isAddingPGPKey = false
375 } catch {
376 self.error = error.userFacingMessage
377 }
378 }
379
380 func deletePGPKey(_ key: PGPKey) async {
381 error = nil
382
383 do {
384 _ = try await client.execute(
385 service: .meta,
386 query: Self.deletePGPKeyMutation,
387 variables: ["id": key.id],
388 responseType: DeletePGPKeyResponse.self
389 )
390 pgpKeys.removeAll { $0.id == key.id }
391 } catch {
392 self.error = error.userFacingMessage
393 }
394 }
395
396 // MARK: - Personal Access Tokens
397
398 func loadPersonalAccessTokens() async {
399 guard !isLoadingPATs else { return }
400 isLoadingPATs = true
401
402 do {
403 let result = try await client.execute(
404 service: .meta,
405 query: Self.personalAccessTokensQuery,
406 responseType: PATListResponse.self
407 )
408 personalAccessTokens = result.personalAccessTokens
409 } catch {
410 self.error = error.userFacingMessage
411 }
412
413 isLoadingPATs = false
414 }
415
416 // MARK: - Audit Log
417
418 /// Loads the most recent audit entries.
419 ///
420 /// Deliberately one page: this is a glanceable "has anything happened to my
421 /// account" surface, not an archive. The full log is on meta.sr.ht.
422 func loadAuditLog() async {
423 guard !isLoadingAuditLog else { return }
424 isLoadingAuditLog = true
425 defer { isLoadingAuditLog = false }
426
427 do {
428 let result = try await client.execute(
429 service: .meta,
430 query: Self.auditLogQuery,
431 responseType: AuditLogResponse.self
432 )
433 auditLog = result.auditLog.results
434 } catch {
435 auditLogError = error.userFacingMessage
436 }
437 }
438}