krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.1.7: 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                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}