krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.4: 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
46// MARK: - View Model
47
48@Observable
49@MainActor
50final class SettingsViewModel {
51
52 private(set) var profile: UserProfile?
53 private(set) var sshKeys: [SSHKey] = []
54 private(set) var pgpKeys: [PGPKey] = []
55 private(set) var personalAccessTokens: [PersonalAccessToken] = []
56
57 private(set) var isLoading = false
58 private(set) var isLoadingPATs = false
59 private(set) var isSavingProfile = false
60 private(set) var isUploadingAvatar = false
61 var error: String?
62
63 var isEditingProfile = false
64
65 // Add SSH key fields
66 var newSSHKey = ""
67 var isAddingSSHKey = false
68
69 // Add PGP key fields
70 var newPGPKey = ""
71 var isAddingPGPKey = false
72
73 private let client: SRHTClient
74
75 init(client: SRHTClient) {
76 self.client = client
77 }
78
79 // MARK: - Queries
80
81 private static let profileQuery = """
82 query me {
83 me {
84 username
85 canonicalName
86 email
87 url
88 location
89 bio
90 avatar
91 userType
92 sshKeys {
93 results { id fingerprint comment created lastUsed }
94 cursor
95 }
96 pgpKeys {
97 results { id fingerprint created }
98 cursor
99 }
100 paymentStatus
101 subscription { status autorenew interval }
102 }
103 }
104 """
105
106 private static let updateUserMutation = """
107 mutation updateUser($input: UserInput!) {
108 updateUser(input: $input) {
109 username email url location bio avatar
110 }
111 }
112 """
113
114 private static let createSSHKeyMutation = """
115 mutation createSSHKey($key: String!) {
116 createSSHKey(key: $key) {
117 id fingerprint comment created lastUsed
118 }
119 }
120 """
121
122 private static let deleteSSHKeyMutation = """
123 mutation deleteSSHKey($id: Int!) {
124 deleteSSHKey(id: $id) { id }
125 }
126 """
127
128 private static let createPGPKeyMutation = """
129 mutation createPGPKey($key: String!) {
130 createPGPKey(key: $key) {
131 id fingerprint created
132 }
133 }
134 """
135
136 private static let deletePGPKeyMutation = """
137 mutation deletePGPKey($id: Int!) {
138 deletePGPKey(id: $id) { id }
139 }
140 """
141
142 private static let personalAccessTokensQuery = """
143 query personalAccessTokens {
144 personalAccessTokens { id issued expires comment grants }
145 }
146 """
147
148 // MARK: - Load Profile
149
150 func loadProfile() async {
151 guard !isLoading else { return }
152 isLoading = true
153 error = nil
154
155 do {
156 let result = try await client.execute(
157 service: .meta,
158 query: Self.profileQuery,
159 responseType: MeProfileResponse.self
160 )
161 profile = result.me
162 sshKeys = result.me.sshKeys.results
163 pgpKeys = result.me.pgpKeys.results
164 } catch {
165 self.error = error.userFacingMessage
166 }
167
168 isLoading = false
169 }
170
171 // MARK: - Update Profile
172
173 func saveProfile(email: String, url: String, location: String, bio: String) async {
174 guard !isSavingProfile else { return }
175 isSavingProfile = true
176 error = nil
177
178 do {
179 let input: [String: any Sendable] = [
180 "email": email,
181 "url": url.isEmpty ? nil as String? as Any : url,
182 "location": location.isEmpty ? nil as String? as Any : location,
183 "bio": bio.isEmpty ? nil as String? as Any : bio
184 ]
185 let result = try await client.execute(
186 service: .meta,
187 query: Self.updateUserMutation,
188 variables: ["input": input],
189 responseType: UpdateUserResponse.self
190 )
191 let updated = result.updateUser
192 if let p = profile {
193 profile = UserProfile(
194 username: p.username,
195 canonicalName: p.canonicalName,
196 email: updated.email,
197 url: updated.url,
198 location: updated.location,
199 bio: updated.bio,
200 avatar: updated.avatar ?? p.avatar,
201 userType: p.userType,
202 sshKeys: p.sshKeys,
203 pgpKeys: p.pgpKeys,
204 paymentStatus: p.paymentStatus,
205 subscription: p.subscription
206 )
207 }
208 isEditingProfile = false
209 } catch {
210 self.error = error.userFacingMessage
211 }
212
213 isSavingProfile = false
214 }
215
216 // MARK: - Avatar
217
218 func uploadAvatar(jpegData: Data) async {
219 guard !isUploadingAvatar else { return }
220 isUploadingAvatar = true
221 error = nil
222
223 do {
224 // The input variable has avatar set to null; the actual file
225 // is sent as a separate multipart part per graphql-multipart-request-spec.
226 let input: [String: any Sendable] = ["avatar": nil as String? as Any]
227 let result = try await client.executeMultipart(
228 service: .meta,
229 query: Self.updateUserMutation,
230 variables: ["input": input],
231 fileVariablePath: "input.avatar",
232 fileData: jpegData,
233 fileName: "avatar.jpg",
234 mimeType: "image/jpeg",
235 responseType: UpdateUserResponse.self
236 )
237 let updated = result.updateUser
238 if let p = profile {
239 profile = UserProfile(
240 username: p.username,
241 canonicalName: p.canonicalName,
242 email: updated.email,
243 url: updated.url,
244 location: updated.location,
245 bio: updated.bio,
246 avatar: updated.avatar ?? p.avatar,
247 userType: p.userType,
248 sshKeys: p.sshKeys,
249 pgpKeys: p.pgpKeys,
250 paymentStatus: p.paymentStatus,
251 subscription: p.subscription
252 )
253 }
254 } catch {
255 self.error = error.userFacingMessage
256 }
257
258 isUploadingAvatar = false
259 }
260
261 func removeAvatar() async {
262 guard !isUploadingAvatar else { return }
263 isUploadingAvatar = true
264 error = nil
265
266 do {
267 let input: [String: any Sendable] = ["avatar": nil as String? as Any]
268 let result = try await client.execute(
269 service: .meta,
270 query: Self.updateUserMutation,
271 variables: ["input": input],
272 responseType: UpdateUserResponse.self
273 )
274 let updated = result.updateUser
275 if let p = profile {
276 profile = UserProfile(
277 username: p.username,
278 canonicalName: p.canonicalName,
279 email: updated.email,
280 url: updated.url,
281 location: updated.location,
282 bio: updated.bio,
283 avatar: nil,
284 userType: p.userType,
285 sshKeys: p.sshKeys,
286 pgpKeys: p.pgpKeys,
287 paymentStatus: p.paymentStatus,
288 subscription: p.subscription
289 )
290 }
291 } catch {
292 self.error = error.userFacingMessage
293 }
294
295 isUploadingAvatar = false
296 }
297
298 // MARK: - SSH Keys
299
300 func addSSHKey() async {
301 let key = newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines)
302 guard !key.isEmpty else { return }
303 error = nil
304
305 do {
306 let result = try await client.execute(
307 service: .meta,
308 query: Self.createSSHKeyMutation,
309 variables: ["key": key],
310 responseType: CreateSSHKeyResponse.self
311 )
312 sshKeys.append(result.createSSHKey)
313 newSSHKey = ""
314 isAddingSSHKey = false
315 } catch {
316 self.error = error.userFacingMessage
317 }
318 }
319
320 func deleteSSHKey(_ key: SSHKey) async {
321 error = nil
322
323 do {
324 _ = try await client.execute(
325 service: .meta,
326 query: Self.deleteSSHKeyMutation,
327 variables: ["id": key.id],
328 responseType: DeleteSSHKeyResponse.self
329 )
330 sshKeys.removeAll { $0.id == key.id }
331 } catch {
332 self.error = error.userFacingMessage
333 }
334 }
335
336 // MARK: - PGP Keys
337
338 func addPGPKey() async {
339 let key = newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines)
340 guard !key.isEmpty else { return }
341 error = nil
342
343 do {
344 let result = try await client.execute(
345 service: .meta,
346 query: Self.createPGPKeyMutation,
347 variables: ["key": key],
348 responseType: CreatePGPKeyResponse.self
349 )
350 pgpKeys.append(result.createPGPKey)
351 newPGPKey = ""
352 isAddingPGPKey = false
353 } catch {
354 self.error = error.userFacingMessage
355 }
356 }
357
358 func deletePGPKey(_ key: PGPKey) async {
359 error = nil
360
361 do {
362 _ = try await client.execute(
363 service: .meta,
364 query: Self.deletePGPKeyMutation,
365 variables: ["id": key.id],
366 responseType: DeletePGPKeyResponse.self
367 )
368 pgpKeys.removeAll { $0.id == key.id }
369 } catch {
370 self.error = error.userFacingMessage
371 }
372 }
373
374 // MARK: - Personal Access Tokens
375
376 func loadPersonalAccessTokens() async {
377 guard !isLoadingPATs else { return }
378 isLoadingPATs = true
379
380 do {
381 let result = try await client.execute(
382 service: .meta,
383 query: Self.personalAccessTokensQuery,
384 responseType: PATListResponse.self
385 )
386 personalAccessTokens = result.personalAccessTokens
387 } catch {
388 self.error = error.userFacingMessage
389 }
390
391 isLoadingPATs = false
392 }
393
394}