krz/hutch

an ios client for sourcehut

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

v3.8.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
 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 : url,
202                "location": location.isEmpty ? nil as String? as Any : location,
203                "bio": bio.isEmpty ? nil as String? as Any : 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]
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]
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}