krz/hutch

an ios client for sourcehut

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

v2.10.2: 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}