From 32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Tue, 17 Mar 2026 23:19:43 -0500 Subject: v1.0 --- Hutch/Views/Settings/SettingsView.swift | 724 +++++++++++++++++++++++++++ Hutch/Views/Settings/SettingsViewModel.swift | 394 +++++++++++++++ 2 files changed, 1118 insertions(+) create mode 100644 Hutch/Views/Settings/SettingsView.swift create mode 100644 Hutch/Views/Settings/SettingsViewModel.swift (limited to 'Hutch/Views/Settings') diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..5bbded8 --- /dev/null +++ b/Hutch/Views/Settings/SettingsView.swift @@ -0,0 +1,724 @@ +import PhotosUI +import SwiftUI + +struct SettingsView: View { + @Environment(AppState.self) private var appState + @Environment(\.colorScheme) private var colorScheme + @State private var viewModel: SettingsViewModel? + @State private var pendingDestructiveAction: SettingsDestructiveAction? + + var body: some View { + NavigationStack { + Group { + if let viewModel { + settingsContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading profile…") + } + } + .navigationTitle("Settings") + .task { + if viewModel == nil { + let vm = SettingsViewModel(client: appState.client) + viewModel = vm + await vm.loadProfile() + } + } + } + } + + @ViewBuilder + private func settingsContent(_ viewModel: SettingsViewModel) -> some View { + @Bindable var vm = viewModel + + Form { + if let profile = viewModel.profile { + // Profile section + profileSection(profile, viewModel: viewModel) + + // SSH Keys + sshKeysSection(viewModel) + + // PGP Keys + pgpKeysSection(viewModel) + + // Personal Access Tokens + patSection(viewModel) + } + + // Token / Sign Out + tokenSection() + + aboutSection() + } + .overlay { + if viewModel.isLoading, viewModel.profile == nil { + SRHTLoadingStateView(message: "Loading profile…") + } else if let error = viewModel.error, viewModel.profile == nil { + SRHTErrorStateView( + title: "Couldn't Load Profile", + message: error, + retryAction: { await viewModel.loadProfile() } + ) + } + } + .sheet(isPresented: $vm.isEditingProfile) { + if let profile = viewModel.profile { + EditProfileSheet( + profile: profile, + viewModel: viewModel + ) + } + } + .alert("Error", isPresented: Binding( + get: { viewModel.error != nil && viewModel.profile != nil }, + set: { isPresented in + if !isPresented { + viewModel.error = nil + } + } + )) { + Button("OK") { viewModel.error = nil } + } message: { + if let error = viewModel.error { + Text(error) + } + } + .alert( + pendingDestructiveAction?.title ?? "", + isPresented: Binding( + get: { pendingDestructiveAction != nil }, + set: { isPresented in + if !isPresented { + pendingDestructiveAction = nil + } + } + ) + ) { + Button("Cancel", role: .cancel) {} + Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) { + guard let action = pendingDestructiveAction else { return } + pendingDestructiveAction = nil + Task { + switch action { + case .resetAppData: + await appState.resetAppData() + case .signOut: + await appState.signOut() + case .deleteSSHKey(let key): + await viewModel.deleteSSHKey(key) + case .deletePGPKey(let key): + await viewModel.deletePGPKey(key) + } + } + } + } message: { + if let pendingDestructiveAction { + Text(pendingDestructiveAction.message) + } + } + .refreshable { + await viewModel.loadProfile() + } + } + + // MARK: - Profile Section + + @ViewBuilder + private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View { + Section("Profile") { + HStack(spacing: 12) { + AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + Image(systemName: "person.crop.circle.fill") + .resizable() + .foregroundStyle(.secondary) + } + } + .frame(width: 56, height: 56) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(profile.canonicalName) + .font(.headline) + Text(profile.email) + .font(.subheadline) + .foregroundStyle(.secondary) + if let userType = profile.userType { + Text(userType.capitalized) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + .padding(.vertical, 4) + + if let bio = profile.bio, !bio.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Bio") + .font(.caption) + .foregroundStyle(.secondary) + RenderedMarkupContentView( + content: .markdown(bio), + readmePath: nil, + colorScheme: colorScheme, + ownerCanonicalName: "", + repositoryName: "" + ) + } + } + + if let location = profile.location, !location.isEmpty { + LabeledContent("Location", value: location) + } + + if let url = profile.url, !url.isEmpty { + LabeledContent("URL", value: url) + } + + if let status = profile.paymentStatus { + LabeledContent("Payment", value: status.capitalized) + } + + if let sub = profile.subscription { + if let status = sub.status { + LabeledContent("Subscription", value: status.capitalized) + } + if let interval = sub.interval { + LabeledContent("Interval", value: interval.capitalized) + } + } + + Button("Edit Profile") { + viewModel.isEditingProfile = true + } + + SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) { + SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up") + } + } + } + + // MARK: - SSH Keys Section + + @ViewBuilder + private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View { + @Bindable var vm = viewModel + + Section { + ForEach(viewModel.sshKeys) { key in + VStack(alignment: .leading, spacing: 2) { + Text(key.fingerprint) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + + HStack { + if let comment = key.comment, !comment.isEmpty { + Text(comment) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + Text(key.created.relativeDescription) + .font(.caption2) + .foregroundStyle(.tertiary) + } + + if let lastUsed = key.lastUsed { + Text("Last used \(lastUsed.relativeDescription)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button("Delete", role: .destructive) { + pendingDestructiveAction = .deleteSSHKey(key) + } + } + } + + if viewModel.isAddingSSHKey { + TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical) + .font(.caption.monospaced()) + .lineLimit(3...6) + + HStack { + Button("Cancel") { + viewModel.isAddingSSHKey = false + viewModel.newSSHKey = "" + } + Spacer() + Button("Add") { + Task { await viewModel.addSSHKey() } + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } else { + Button { + viewModel.isAddingSSHKey = true + } label: { + SwiftUI.Label("Add SSH Key", systemImage: "key") + } + } + } header: { + Text("SSH Keys") + } footer: { + Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")") + } + } + + // MARK: - PGP Keys Section + + @ViewBuilder + private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View { + @Bindable var vm = viewModel + + Section { + ForEach(viewModel.pgpKeys) { key in + VStack(alignment: .leading, spacing: 2) { + Text(key.fingerprint) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + + Text(key.created.relativeDescription) + .font(.caption2) + .foregroundStyle(.tertiary) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button("Delete", role: .destructive) { + pendingDestructiveAction = .deletePGPKey(key) + } + } + } + + if viewModel.isAddingPGPKey { + TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical) + .font(.caption.monospaced()) + .lineLimit(3...6) + + HStack { + Button("Cancel") { + viewModel.isAddingPGPKey = false + viewModel.newPGPKey = "" + } + Spacer() + Button("Add") { + Task { await viewModel.addPGPKey() } + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } else { + Button { + viewModel.isAddingPGPKey = true + } label: { + SwiftUI.Label("Add PGP Key", systemImage: "key.fill") + } + } + } header: { + Text("PGP Keys") + } footer: { + Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")") + } + } + + // MARK: - Personal Access Tokens Section + + @ViewBuilder + private func patSection(_ viewModel: SettingsViewModel) -> some View { + Section { + if viewModel.isLoadingPATs { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if viewModel.personalAccessTokens.isEmpty { + Button("Load Tokens") { + Task { await viewModel.loadPersonalAccessTokens() } + } + } else { + ForEach(viewModel.personalAccessTokens) { token in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(token.comment ?? "Token #\(token.id)") + .font(.subheadline) + Spacer() + } + + HStack(spacing: 12) { + Text("Issued \(token.issued.relativeDescription)") + .font(.caption2) + .foregroundStyle(.secondary) + + if let expires = token.expires { + Text("Expires \(expires.relativeDescription)") + .font(.caption2) + .foregroundStyle(expires < Date.now ? .red : .secondary) + } + } + + if let grants = token.grants, !grants.isEmpty { + Text(grants) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .lineLimit(2) + } + } + } + } + } header: { + Text("Personal Access Tokens") + } footer: { + if !viewModel.personalAccessTokens.isEmpty { + Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")") + } + } + } + + // MARK: - Token / Sign Out Section + + @ViewBuilder + private func tokenSection() -> some View { + Section { + HStack { + Image(systemName: "key.fill") + .foregroundStyle(.secondary) + Text("Personal access token in use") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + + Button("Reset App Data", role: .destructive) { + pendingDestructiveAction = .resetAppData + } + + Button("Sign Out", role: .destructive) { + pendingDestructiveAction = .signOut + } + } header: { + Text("Authentication") + } footer: { + Text("Hutch stores your SourceHut token in the iOS keychain. Reset App Data removes saved token data, local settings, cached responses, cookies, and embedded web data on this device.") + } + } + + @ViewBuilder + private func aboutSection() -> some View { + Section("App") { + NavigationLink { + AboutView() + } label: { + SwiftUI.Label("About Hutch", systemImage: "info.circle") + } + } + } +} + +// MARK: - Edit Profile Sheet + +private struct EditProfileSheet: View { + let profile: UserProfile + let viewModel: SettingsViewModel + + @State private var email: String + @State private var url: String + @State private var location: String + @State private var bio: String + @State private var selectedPhoto: PhotosPickerItem? + @State private var avatarPreview: UIImage? + @State private var isShowingRemoveAvatarConfirmation = false + + @Environment(\.dismiss) private var dismiss + + init(profile: UserProfile, viewModel: SettingsViewModel) { + self.profile = profile + self.viewModel = viewModel + _email = State(initialValue: profile.email) + _url = State(initialValue: profile.url ?? "") + _location = State(initialValue: profile.location ?? "") + _bio = State(initialValue: profile.bio ?? "") + } + + var body: some View { + NavigationStack { + Form { + Section { + HStack { + Spacer() + VStack(spacing: 8) { + PhotosPicker(selection: $selectedPhoto, matching: .images) { + Group { + if let avatarPreview { + Image(uiImage: avatarPreview) + .resizable() + .scaledToFill() + } else { + AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + Image(systemName: "person.crop.circle.fill") + .resizable() + .foregroundStyle(.secondary) + } + } + } + } + .frame(width: 80, height: 80) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(.secondary.opacity(0.3), lineWidth: 1) + ) + } + + Text("Tap to change avatar") + .font(.caption) + .foregroundStyle(.secondary) + + if viewModel.isUploadingAvatar { + ProgressView() + .controlSize(.small) + } + } + Spacer() + } + .listRowBackground(Color.clear) + + if profile.avatar != nil || avatarPreview != nil { + HStack { + Spacer() + Button(role: .destructive) { + isShowingRemoveAvatarConfirmation = true + } label: { + Text("Remove Avatar") + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isUploadingAvatar) + Spacer() + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + + Section("Edit Profile") { + VStack(alignment: .leading, spacing: 4) { + Text("Email") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Enter email", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + VStack(alignment: .leading, spacing: 4) { + Text("URL") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Enter URL", text: $url) + .textContentType(.URL) + .keyboardType(.URL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Location") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Enter location", text: $location) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Bio") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Enter bio", text: $bio, axis: .vertical) + .lineLimit(3...6) + } + } + } + .navigationTitle("Edit Profile") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: selectedPhoto) { _, newItem in + guard let newItem else { return } + Task { + if let data = try? await newItem.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + avatarPreview = image + // Encode as JPEG and upload + if let jpegData = image.jpegData(compressionQuality: 0.85) { + await viewModel.uploadAvatar(jpegData: jpegData) + } + } + } + } + .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Remove Avatar", role: .destructive) { + Task { + await viewModel.removeAvatar() + if viewModel.error == nil { + avatarPreview = nil + selectedPhoto = nil + } + } + } + } message: { + Text("Your profile avatar will be removed from SourceHut.") + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await viewModel.saveProfile( + email: email, + url: url, + location: location, + bio: bio + ) + if viewModel.error == nil { + dismiss() + } + } + } label: { + if viewModel.isSavingProfile { + ProgressView() + .controlSize(.small) + } else { + Text("Save") + } + } + .disabled(viewModel.isSavingProfile) + } + } + } + } +} + +private enum SettingsDestructiveAction { + case resetAppData + case signOut + case deleteSSHKey(SSHKey) + case deletePGPKey(PGPKey) + + var title: String { + switch self { + case .resetAppData: + "Reset App Data?" + case .signOut: + "Sign Out?" + case .deleteSSHKey: + "Remove SSH Key?" + case .deletePGPKey: + "Remove PGP Key?" + } + } + + var confirmationLabel: String { + switch self { + case .resetAppData: + "Reset App Data" + case .signOut: + "Sign Out" + case .deleteSSHKey: + "Remove SSH Key" + case .deletePGPKey: + "Remove PGP Key" + } + } + + var message: String { + switch self { + case .resetAppData: + "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device." + case .signOut: + "This signs you out of Hutch and clears saved authentication state on this device." + case .deleteSSHKey(let key): + "Remove SSH key \(key.fingerprint) from your account?" + case .deletePGPKey(let key): + "Remove PGP key \(key.fingerprint) from your account?" + } + } +} + +private struct AboutView: View { + private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "Hutch" + private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? "Unknown" + private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + ?? "Unknown" + + var body: some View { + Form { + Section { + VStack(alignment: .leading, spacing: 6) { + Text(appName) + .font(.title2.weight(.semibold)) + Text("A native SourceHut client for iPhone.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + + LabeledContent("Version", value: version) + LabeledContent("Build", value: build) + } + + Section("Links") { + Link(destination: URL(string: "https://sr.ht")!) { + SwiftUI.Label("SourceHut", systemImage: "link") + } + Link(destination: URL(string: "https://man.sr.ht")!) { + SwiftUI.Label("SourceHut Manuals", systemImage: "book") + } + Link(destination: URL(string: "https://git.sr.ht/~ccleberg/Hutch")!) { + SwiftUI.Label("Project Repository", systemImage: "folder") + } + } + + Section("Support") { + Link(destination: URL(string: "mailto:hello@cleberg.net")!) { + SwiftUI.Label("Email Support", systemImage: "envelope") + } + } + + Section("Privacy") { + Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Section("Acknowledgements") { + Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iPhone.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .navigationTitle("About") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..204afbf --- /dev/null +++ b/Hutch/Views/Settings/SettingsViewModel.swift @@ -0,0 +1,394 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct MeProfileResponse: Decodable, Sendable { + let me: UserProfile +} + +private struct UpdateUserResponse: Decodable, Sendable { + let updateUser: UpdatedUser +} + +private struct UpdatedUser: Decodable, Sendable { + let username: String + let email: String + let url: String? + let location: String? + let bio: String? + let avatar: String? +} + +private struct CreateSSHKeyResponse: Decodable, Sendable { + let createSSHKey: SSHKey +} + +private struct DeleteSSHKeyResponse: Decodable, Sendable { + let deleteSSHKey: DeleteResult +} + +private struct CreatePGPKeyResponse: Decodable, Sendable { + let createPGPKey: PGPKey +} + +private struct DeletePGPKeyResponse: Decodable, Sendable { + let deletePGPKey: DeleteResult +} + +private struct DeleteResult: Decodable, Sendable { + let id: Int? +} + +private struct PATListResponse: Decodable, Sendable { + let personalAccessTokens: [PersonalAccessToken] +} + +// MARK: - View Model + +@Observable +@MainActor +final class SettingsViewModel { + + private(set) var profile: UserProfile? + private(set) var sshKeys: [SSHKey] = [] + private(set) var pgpKeys: [PGPKey] = [] + private(set) var personalAccessTokens: [PersonalAccessToken] = [] + + private(set) var isLoading = false + private(set) var isLoadingPATs = false + private(set) var isSavingProfile = false + private(set) var isUploadingAvatar = false + var error: String? + + var isEditingProfile = false + + // Add SSH key fields + var newSSHKey = "" + var isAddingSSHKey = false + + // Add PGP key fields + var newPGPKey = "" + var isAddingPGPKey = false + + private let client: SRHTClient + + init(client: SRHTClient) { + self.client = client + } + + // MARK: - Queries + + private static let profileQuery = """ + query me { + me { + username + canonicalName + email + url + location + bio + avatar + userType + sshKeys { + results { id fingerprint comment created lastUsed } + cursor + } + pgpKeys { + results { id fingerprint created } + cursor + } + paymentStatus + subscription { status autorenew interval } + } + } + """ + + private static let updateUserMutation = """ + mutation updateUser($input: UserInput!) { + updateUser(input: $input) { + username email url location bio avatar + } + } + """ + + private static let createSSHKeyMutation = """ + mutation createSSHKey($key: String!) { + createSSHKey(key: $key) { + id fingerprint comment created lastUsed + } + } + """ + + private static let deleteSSHKeyMutation = """ + mutation deleteSSHKey($id: Int!) { + deleteSSHKey(id: $id) { id } + } + """ + + private static let createPGPKeyMutation = """ + mutation createPGPKey($key: String!) { + createPGPKey(key: $key) { + id fingerprint created + } + } + """ + + private static let deletePGPKeyMutation = """ + mutation deletePGPKey($id: Int!) { + deletePGPKey(id: $id) { id } + } + """ + + private static let personalAccessTokensQuery = """ + query personalAccessTokens { + personalAccessTokens { id issued expires comment grants } + } + """ + + // MARK: - Load Profile + + func loadProfile() async { + guard !isLoading else { return } + isLoading = true + error = nil + + do { + let result = try await client.execute( + service: .meta, + query: Self.profileQuery, + responseType: MeProfileResponse.self + ) + profile = result.me + sshKeys = result.me.sshKeys.results + pgpKeys = result.me.pgpKeys.results + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + // MARK: - Update Profile + + func saveProfile(email: String, url: String, location: String, bio: String) async { + guard !isSavingProfile else { return } + isSavingProfile = true + error = nil + + do { + let input: [String: any Sendable] = [ + "email": email, + "url": url.isEmpty ? nil as String? as Any : url, + "location": location.isEmpty ? nil as String? as Any : location, + "bio": bio.isEmpty ? nil as String? as Any : bio + ] + let result = try await client.execute( + service: .meta, + query: Self.updateUserMutation, + variables: ["input": input], + responseType: UpdateUserResponse.self + ) + let updated = result.updateUser + if let p = profile { + profile = UserProfile( + username: p.username, + canonicalName: p.canonicalName, + email: updated.email, + url: updated.url, + location: updated.location, + bio: updated.bio, + avatar: updated.avatar ?? p.avatar, + userType: p.userType, + sshKeys: p.sshKeys, + pgpKeys: p.pgpKeys, + paymentStatus: p.paymentStatus, + subscription: p.subscription + ) + } + isEditingProfile = false + } catch { + self.error = error.localizedDescription + } + + isSavingProfile = false + } + + // MARK: - Avatar + + func uploadAvatar(jpegData: Data) async { + guard !isUploadingAvatar else { return } + isUploadingAvatar = true + error = nil + + do { + // The input variable has avatar set to null; the actual file + // is sent as a separate multipart part per graphql-multipart-request-spec. + let input: [String: any Sendable] = ["avatar": nil as String? as Any] + let result = try await client.executeMultipart( + service: .meta, + query: Self.updateUserMutation, + variables: ["input": input], + fileVariablePath: "input.avatar", + fileData: jpegData, + fileName: "avatar.jpg", + mimeType: "image/jpeg", + responseType: UpdateUserResponse.self + ) + let updated = result.updateUser + if let p = profile { + profile = UserProfile( + username: p.username, + canonicalName: p.canonicalName, + email: updated.email, + url: updated.url, + location: updated.location, + bio: updated.bio, + avatar: updated.avatar ?? p.avatar, + userType: p.userType, + sshKeys: p.sshKeys, + pgpKeys: p.pgpKeys, + paymentStatus: p.paymentStatus, + subscription: p.subscription + ) + } + } catch { + self.error = error.localizedDescription + } + + isUploadingAvatar = false + } + + func removeAvatar() async { + guard !isUploadingAvatar else { return } + isUploadingAvatar = true + error = nil + + do { + let input: [String: any Sendable] = ["avatar": nil as String? as Any] + let result = try await client.execute( + service: .meta, + query: Self.updateUserMutation, + variables: ["input": input], + responseType: UpdateUserResponse.self + ) + let updated = result.updateUser + if let p = profile { + profile = UserProfile( + username: p.username, + canonicalName: p.canonicalName, + email: updated.email, + url: updated.url, + location: updated.location, + bio: updated.bio, + avatar: nil, + userType: p.userType, + sshKeys: p.sshKeys, + pgpKeys: p.pgpKeys, + paymentStatus: p.paymentStatus, + subscription: p.subscription + ) + } + } catch { + self.error = error.localizedDescription + } + + isUploadingAvatar = false + } + + // MARK: - SSH Keys + + func addSSHKey() async { + let key = newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + error = nil + + do { + let result = try await client.execute( + service: .meta, + query: Self.createSSHKeyMutation, + variables: ["key": key], + responseType: CreateSSHKeyResponse.self + ) + sshKeys.append(result.createSSHKey) + newSSHKey = "" + isAddingSSHKey = false + } catch { + self.error = error.localizedDescription + } + } + + func deleteSSHKey(_ key: SSHKey) async { + error = nil + + do { + _ = try await client.execute( + service: .meta, + query: Self.deleteSSHKeyMutation, + variables: ["id": key.id], + responseType: DeleteSSHKeyResponse.self + ) + sshKeys.removeAll { $0.id == key.id } + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - PGP Keys + + func addPGPKey() async { + let key = newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + error = nil + + do { + let result = try await client.execute( + service: .meta, + query: Self.createPGPKeyMutation, + variables: ["key": key], + responseType: CreatePGPKeyResponse.self + ) + pgpKeys.append(result.createPGPKey) + newPGPKey = "" + isAddingPGPKey = false + } catch { + self.error = error.localizedDescription + } + } + + func deletePGPKey(_ key: PGPKey) async { + error = nil + + do { + _ = try await client.execute( + service: .meta, + query: Self.deletePGPKeyMutation, + variables: ["id": key.id], + responseType: DeletePGPKeyResponse.self + ) + pgpKeys.removeAll { $0.id == key.id } + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Personal Access Tokens + + func loadPersonalAccessTokens() async { + guard !isLoadingPATs else { return } + isLoadingPATs = true + + do { + let result = try await client.execute( + service: .meta, + query: Self.personalAccessTokensQuery, + responseType: PATListResponse.self + ) + personalAccessTokens = result.personalAccessTokens + } catch { + self.error = error.localizedDescription + } + + isLoadingPATs = false + } + +} -- cgit v1.2.3