krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.3.1: 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 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 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 file: MultipartUploadFile(
232 variablePath: "input.avatar",
233 fileData: jpegData,
234 fileName: "avatar.jpg",
235 mimeType: "image/jpeg"
236 ),
237 responseType: UpdateUserResponse.self
238 )
239 let updated = result.updateUser
240 if let p = profile {
241 profile = UserProfile(
242 username: p.username,
243 canonicalName: p.canonicalName,
244 email: updated.email,
245 url: updated.url,
246 location: updated.location,
247 bio: updated.bio,
248 avatar: updated.avatar ?? p.avatar,
249 userType: p.userType,
250 sshKeys: p.sshKeys,
251 pgpKeys: p.pgpKeys,
252 paymentStatus: p.paymentStatus,
253 subscription: p.subscription
254 )
255 }
256 } catch {
257 self.error = error.userFacingMessage
258 }
259
260 isUploadingAvatar = false
261 }
262
263 func removeAvatar() async {
264 guard !isUploadingAvatar else { return }
265 isUploadingAvatar = true
266 error = nil
267
268 do {
269 let input: [String: any Sendable] = ["avatar": nil as String? as Any]
270 let result = try await client.execute(
271 service: .meta,
272 query: Self.updateUserMutation,
273 variables: ["input": input],
274 responseType: UpdateUserResponse.self
275 )
276 let updated = result.updateUser
277 if let p = profile {
278 profile = UserProfile(
279 username: p.username,
280 canonicalName: p.canonicalName,
281 email: updated.email,
282 url: updated.url,
283 location: updated.location,
284 bio: updated.bio,
285 avatar: nil,
286 userType: p.userType,
287 sshKeys: p.sshKeys,
288 pgpKeys: p.pgpKeys,
289 paymentStatus: p.paymentStatus,
290 subscription: p.subscription
291 )
292 }
293 } catch {
294 self.error = error.userFacingMessage
295 }
296
297 isUploadingAvatar = false
298 }
299
300 // MARK: - SSH Keys
301
302 func addSSHKey() async {
303 let key = newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines)
304 guard !key.isEmpty else { return }
305 error = nil
306
307 do {
308 let result = try await client.execute(
309 service: .meta,
310 query: Self.createSSHKeyMutation,
311 variables: ["key": key],
312 responseType: CreateSSHKeyResponse.self
313 )
314 sshKeys.append(result.createSSHKey)
315 newSSHKey = ""
316 isAddingSSHKey = false
317 } catch {
318 self.error = error.userFacingMessage
319 }
320 }
321
322 func deleteSSHKey(_ key: SSHKey) async {
323 error = nil
324
325 do {
326 _ = try await client.execute(
327 service: .meta,
328 query: Self.deleteSSHKeyMutation,
329 variables: ["id": key.id],
330 responseType: DeleteSSHKeyResponse.self
331 )
332 sshKeys.removeAll { $0.id == key.id }
333 } catch {
334 self.error = error.userFacingMessage
335 }
336 }
337
338 // MARK: - PGP Keys
339
340 func addPGPKey() async {
341 let key = newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines)
342 guard !key.isEmpty else { return }
343 error = nil
344
345 do {
346 let result = try await client.execute(
347 service: .meta,
348 query: Self.createPGPKeyMutation,
349 variables: ["key": key],
350 responseType: CreatePGPKeyResponse.self
351 )
352 pgpKeys.append(result.createPGPKey)
353 newPGPKey = ""
354 isAddingPGPKey = false
355 } catch {
356 self.error = error.userFacingMessage
357 }
358 }
359
360 func deletePGPKey(_ key: PGPKey) async {
361 error = nil
362
363 do {
364 _ = try await client.execute(
365 service: .meta,
366 query: Self.deletePGPKeyMutation,
367 variables: ["id": key.id],
368 responseType: DeletePGPKeyResponse.self
369 )
370 pgpKeys.removeAll { $0.id == key.id }
371 } catch {
372 self.error = error.userFacingMessage
373 }
374 }
375
376 // MARK: - Personal Access Tokens
377
378 func loadPersonalAccessTokens() async {
379 guard !isLoadingPATs else { return }
380 isLoadingPATs = true
381
382 do {
383 let result = try await client.execute(
384 service: .meta,
385 query: Self.personalAccessTokensQuery,
386 responseType: PATListResponse.self
387 )
388 personalAccessTokens = result.personalAccessTokens
389 } catch {
390 self.error = error.userFacingMessage
391 }
392
393 isLoadingPATs = false
394 }
395
396}