summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Lists/MailingListListView.swift2
-rw-r--r--Hutch/Views/More/TipStoreViewModel.swift4
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift143
-rw-r--r--Hutch/Views/Repositories/RepositoryDeployKeysView.swift272
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift16
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift2
-rw-r--r--Hutch/Views/Repositories/SyntaxHighlighter.swift6
-rw-r--r--Hutch/Views/Settings/SettingsViewModel.swift10
8 files changed, 444 insertions, 11 deletions
diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift
index 1b159bf..b6ed2ff 100644
--- a/Hutch/Views/Lists/MailingListListView.swift
+++ b/Hutch/Views/Lists/MailingListListView.swift
@@ -100,7 +100,7 @@ final class MailingListListViewModel {
query: Self.createMailingListMutation,
variables: [
"name": trimmedName,
- "description": trimmedDescription.isEmpty ? nil as String? as Any : trimmedDescription,
+ "description": trimmedDescription.isEmpty ? nil as String? as any Sendable : trimmedDescription,
"visibility": visibility.rawValue
],
responseType: Response.self
diff --git a/Hutch/Views/More/TipStoreViewModel.swift b/Hutch/Views/More/TipStoreViewModel.swift
index bc909c1..043b218 100644
--- a/Hutch/Views/More/TipStoreViewModel.swift
+++ b/Hutch/Views/More/TipStoreViewModel.swift
@@ -32,7 +32,9 @@ final class TipStoreViewModel {
var errorMessage: String?
var statusMessage: String?
- private var transactionUpdatesTask: Task<Void, Never>?
+ // Internal task handle, not observable state; assigned only on the main
+ // actor and read once from the nonisolated deinit.
+ @ObservationIgnored nonisolated(unsafe) private var transactionUpdatesTask: Task<Void, Never>?
init() {
transactionUpdatesTask = Task.detached(priority: .background) {
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index 10bb19e..a4decbb 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -37,6 +37,36 @@ private struct PatchsetSummaryPayload: Decodable, Sendable {
let status: PatchsetStatus
}
+private struct ListMetaResponse: Decodable, Sendable {
+ let list: ListMetaPayload?
+}
+
+private struct ListMetaPayload: Decodable, Sendable {
+ let id: Int
+ let owner: Entity
+}
+
+private struct SubscriptionRidsResponse: Decodable, Sendable {
+ let subscriptions: SubscriptionRidsPage
+}
+
+private struct SubscriptionRidsPage: Decodable, Sendable {
+ let results: [SubscriptionRidEntry]
+ let cursor: String?
+}
+
+private struct SubscriptionRidEntry: Decodable, Sendable {
+ let list: SubscriptionRidList?
+}
+
+private struct SubscriptionRidList: Decodable, Sendable {
+ let rid: String
+}
+
+/// Toggle mutations return the subscription (nullable on unsubscribe); only
+/// success matters.
+private struct SubscriptionToggleResponse: Decodable, Sendable {}
+
@Observable
@MainActor
final class MailingListDetailViewModel {
@@ -47,6 +77,13 @@ final class MailingListDetailViewModel {
var error: String?
var searchText = ""
+ /// Subscription state. `isSubscribed` is `nil` while unknown or unavailable
+ /// (the toggle stays hidden); `isOwnList` hides it for lists you own.
+ private(set) var listNumericID: Int?
+ private(set) var isSubscribed: Bool?
+ private(set) var isOwnList = false
+ private(set) var isTogglingSubscription = false
+
private let mailingList: InboxMailingListReference
private let client: SRHTClient
private let defaults: UserDefaults
@@ -86,6 +123,96 @@ final class MailingListDetailViewModel {
self.accountID = accountID
}
+ // MARK: - Subscription
+
+ private static let listMetaQuery = """
+ query listMeta($rid: ID!) {
+ list(rid: $rid) { id owner { canonicalName } }
+ }
+ """
+
+ // `MailingList.subscription` is unreliable (see the API-traps note), so
+ // subscribe state comes from the authoritative `subscriptions` query.
+ private static let subscriptionRidsQuery = """
+ query subscriptionRids($cursor: Cursor) {
+ subscriptions(cursor: $cursor) {
+ results {
+ ... on MailingListSubscription { list { rid } }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let subscribeMutation = """
+ mutation mailingListSubscribe($id: Int!) {
+ mailingListSubscribe(listID: $id) { id }
+ }
+ """
+
+ private static let unsubscribeMutation = """
+ mutation mailingListUnsubscribe($id: Int!) {
+ mailingListUnsubscribe(listID: $id) { id }
+ }
+ """
+
+ /// Resolves the list's numeric id, whether the viewer owns it, and — for
+ /// lists they don't own — whether they're subscribed.
+ func loadSubscriptionState(currentUserCanonicalName: String?) async {
+ do {
+ let meta = try await client.execute(
+ service: .lists,
+ query: Self.listMetaQuery,
+ variables: ["rid": mailingList.rid],
+ responseType: ListMetaResponse.self
+ )
+ guard let list = meta.list else { return }
+ listNumericID = list.id
+
+ if let currentUserCanonicalName, list.owner.canonicalName == currentUserCanonicalName {
+ isOwnList = true
+ return
+ }
+ isSubscribed = try await isSubscribed(toRid: mailingList.rid)
+ } catch {
+ // Leave state unknown; the toggle stays hidden rather than lying.
+ }
+ }
+
+ private func isSubscribed(toRid rid: String) async throws -> Bool {
+ var cursor: String?
+ repeat {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.subscriptionRidsQuery,
+ variables: cursor.map { ["cursor": $0] },
+ responseType: SubscriptionRidsResponse.self
+ )
+ if response.subscriptions.results.contains(where: { $0.list?.rid == rid }) {
+ return true
+ }
+ cursor = response.subscriptions.cursor
+ } while cursor != nil
+ return false
+ }
+
+ func toggleSubscription() async {
+ guard let id = listNumericID, let subscribed = isSubscribed, !isTogglingSubscription else { return }
+ isTogglingSubscription = true
+ defer { isTogglingSubscription = false }
+ do {
+ _ = try await client.execute(
+ service: .lists,
+ query: subscribed ? Self.unsubscribeMutation : Self.subscribeMutation,
+ variables: ["id": id],
+ responseType: SubscriptionToggleResponse.self
+ )
+ isSubscribed = !subscribed
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+
var filteredThreads: [InboxThreadSummary] {
Self.filterThreads(threads, matching: searchText)
}
@@ -386,6 +513,21 @@ struct MailingListDetailView: View {
.accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
}
}
+ if let viewModel, !viewModel.isOwnList, let subscribed = viewModel.isSubscribed {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ Task { await viewModel.toggleSubscription() }
+ } label: {
+ if viewModel.isTogglingSubscription {
+ ProgressView().controlSize(.small)
+ } else {
+ Image(systemName: subscribed ? "bell.fill" : "bell")
+ }
+ }
+ .disabled(viewModel.isTogglingSubscription)
+ .accessibilityLabel(subscribed ? "Unsubscribe from list" : "Subscribe to list")
+ }
+ }
}
.task {
if viewModel == nil {
@@ -397,6 +539,7 @@ struct MailingListDetailView: View {
)
self.viewModel = viewModel
await viewModel.loadThreads()
+ await viewModel.loadSubscriptionState(currentUserCanonicalName: currentUserKey)
}
}
.onAppear {
diff --git a/Hutch/Views/Repositories/RepositoryDeployKeysView.swift b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift
new file mode 100644
index 0000000..71e252b
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift
@@ -0,0 +1,272 @@
+import SwiftUI
+
+@Observable
+@MainActor
+final class RepositoryDeployKeysViewModel {
+ private(set) var keys: [RepositoryDeployKey] = []
+ private(set) var isLoading = false
+ private(set) var isSaving = false
+ private(set) var deletingRID: String?
+ var loadError: String?
+ var error: String?
+ var saveError: String?
+
+ let repositoryRid: String
+ private let service: RepositoryDeployKeyService
+
+ init(repositoryRid: String, service: RepositoryDeployKeyService) {
+ self.repositoryRid = repositoryRid
+ self.service = service
+ }
+
+ func load() async {
+ guard !isLoading else { return }
+ isLoading = true
+ loadError = nil
+ defer { isLoading = false }
+ do {
+ keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
+ } catch {
+ if keys.isEmpty {
+ loadError = error.userFacingMessage
+ } else {
+ self.error = error.userFacingMessage
+ }
+ }
+ }
+
+ func addKey(publicKey: String, mode: AccessMode) async -> Bool {
+ let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, !isSaving else { return false }
+ isSaving = true
+ saveError = nil
+ defer { isSaving = false }
+ do {
+ try await service.createDeployKey(repositoryRid: repositoryRid, mode: mode, key: trimmed)
+ // The create response omits the key's fields, so reload the list.
+ keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
+ return true
+ } catch {
+ saveError = error.userFacingMessage
+ return false
+ }
+ }
+
+ func deleteKey(_ key: RepositoryDeployKey) async {
+ guard deletingRID == nil else { return }
+ deletingRID = key.rid
+ error = nil
+ defer { deletingRID = nil }
+ do {
+ try await service.deleteDeployKey(rid: key.rid)
+ keys.removeAll { $0.rid == key.rid }
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+}
+
+struct RepositoryDeployKeysView: View {
+ let repository: RepositorySummary
+ let client: SRHTClient
+ var showsDoneButton = false
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: RepositoryDeployKeysViewModel?
+ @State private var showAddSheet = false
+ @State private var pendingDeletion: RepositoryDeployKey?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading deploy keys…")
+ }
+ }
+ .navigationTitle("Deploy Keys")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ if showsDoneButton {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showAddSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ .accessibilityLabel("Add deploy key")
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = RepositoryDeployKeysViewModel(
+ repositoryRid: repository.rid,
+ service: RepositoryDeployKeyService(client: client)
+ )
+ viewModel = vm
+ await vm.load()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: RepositoryDeployKeysViewModel) -> some View {
+ Group {
+ if viewModel.isLoading, viewModel.keys.isEmpty, viewModel.loadError == nil {
+ SRHTLoadingStateView(message: "Loading deploy keys…")
+ } else if let loadError = viewModel.loadError, viewModel.keys.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Deploy Keys",
+ message: loadError,
+ retryAction: { await viewModel.load() }
+ )
+ } else {
+ List {
+ if viewModel.keys.isEmpty {
+ Section {
+ ContentUnavailableView(
+ "No Deploy Keys",
+ systemImage: "key",
+ description: Text("Add an SSH public key to grant this repository read or read/write access for automation.")
+ )
+ .themedRow()
+ }
+ } else {
+ Section {
+ ForEach(viewModel.keys) { key in
+ DeployKeyRow(key: key, isDeleting: viewModel.deletingRID == key.rid)
+ .themedRow()
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button(role: .destructive) {
+ pendingDeletion = key
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+ } footer: {
+ Text("Deploy keys are SSH keys scoped to this repository only.")
+ }
+ }
+ }
+ .themedList()
+ .refreshable { await viewModel.load() }
+ }
+ }
+ .srhtErrorBanner(error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 }))
+ .confirmationDialog(
+ "Delete this deploy key?",
+ isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }),
+ titleVisibility: .visible
+ ) {
+ Button("Cancel", role: .cancel) { pendingDeletion = nil }
+ Button("Delete", role: .destructive) {
+ if let key = pendingDeletion {
+ pendingDeletion = nil
+ Task { await viewModel.deleteKey(key) }
+ }
+ }
+ } message: {
+ Text("This revokes the key's access to \(repository.name). This cannot be undone.")
+ }
+ .sheet(isPresented: $showAddSheet) {
+ AddDeployKeyView(viewModel: viewModel)
+ }
+ }
+}
+
+private struct DeployKeyRow: View {
+ let key: RepositoryDeployKey
+ let isDeleting: Bool
+
+ var body: some View {
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(key.comment?.isEmpty == false ? key.comment! : key.keyType)
+ .font(.body)
+ .lineLimit(1)
+ Text(key.fingerprintSHA256)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ Spacer()
+ if isDeleting {
+ ProgressView().controlSize(.small)
+ } else {
+ Text(key.access.displayName)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+private struct AddDeployKeyView: View {
+ let viewModel: RepositoryDeployKeysViewModel
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var publicKey = ""
+ @State private var mode: AccessMode = .ro
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("SSH Public Key") {
+ TextField("ssh-ed25519 AAAA… comment", text: $publicKey, axis: .vertical)
+ .lineLimit(3...8)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .font(.body.monospaced())
+ .themedRow()
+ }
+ Section {
+ Picker("Access", selection: $mode) {
+ Text("Read Only").tag(AccessMode.ro)
+ Text("Read/Write").tag(AccessMode.rw)
+ }
+ .themedRow()
+ } footer: {
+ Text("Read/Write lets the key push to this repository.")
+ }
+ if let saveError = viewModel.saveError, !saveError.isEmpty {
+ Section {
+ Text(saveError).foregroundStyle(.red).themedRow()
+ }
+ }
+ }
+ .themedList()
+ .navigationTitle("Add Deploy Key")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if await viewModel.addKey(publicKey: publicKey, mode: mode) {
+ dismiss()
+ }
+ }
+ } label: {
+ if viewModel.isSaving {
+ ProgressView().controlSize(.small)
+ } else {
+ Text("Add")
+ }
+ }
+ .disabled(publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
index 8f466ba..a4ac25e 100644
--- a/Hutch/Views/Repositories/RepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -12,6 +12,7 @@ struct RepositoryDetailView: View {
@State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
@State private var showSettings = false
@State private var showACLs = false
+ @State private var showDeployKeys = false
@State private var currentRepository: RepositorySummary
@State private var pinChangeCount = 0
@@ -82,6 +83,15 @@ struct RepositoryDetailView: View {
)
}
}
+ .sheet(isPresented: $showDeployKeys) {
+ NavigationStack {
+ RepositoryDeployKeysView(
+ repository: currentRepository,
+ client: appState.client,
+ showsDoneButton: true
+ )
+ }
+ }
.task {
if viewModel == nil {
viewModel = RepositoryDetailViewModel(
@@ -201,6 +211,12 @@ struct RepositoryDetailView: View {
}
Button {
+ showDeployKeys = true
+ } label: {
+ Label("Deploy Keys", systemImage: "key")
+ }
+
+ Button {
showSettings = true
} label: {
Label("Repository Settings", systemImage: "gear")
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
index 82e1592..62a8b12 100644
--- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -557,7 +557,7 @@ final class RepositoryDetailViewModel {
variables: [
"repoId": repository.id,
"revspec": revspec,
- "file": nil as String? as Any
+ "file": nil as String? as any Sendable
],
file: MultipartUploadFile(
variablePath: "file",
diff --git a/Hutch/Views/Repositories/SyntaxHighlighter.swift b/Hutch/Views/Repositories/SyntaxHighlighter.swift
index 5ae8f58..dfab443 100644
--- a/Hutch/Views/Repositories/SyntaxHighlighter.swift
+++ b/Hutch/Views/Repositories/SyntaxHighlighter.swift
@@ -3,7 +3,7 @@ import Highlightr
import SwiftUI
import UIKit
-enum SyntaxHighlightTheme {
+nonisolated enum SyntaxHighlightTheme {
case light
case dark
@@ -31,7 +31,7 @@ enum SyntaxHighlightTheme {
/// instance must stay on the thread/task that created it. Callers that fail to
/// resolve a language — or hit an unavailable engine — get `nil` and should
/// fall back to plain, escaped text.
-final class SyntaxHighlighter {
+nonisolated final class SyntaxHighlighter {
private let highlightr: Highlightr?
private let supportedLanguages: Set<String>
@@ -208,7 +208,7 @@ final class SyntaxHighlighter {
]
}
-private extension UIColor {
+nonisolated private extension UIColor {
/// `#rrggbb` for HTML inline styles, or `nil` if the color isn't RGB-convertible.
var hexRGBString: String? {
var red: CGFloat = 0
diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift
index 8536e60..2d57ba5 100644
--- a/Hutch/Views/Settings/SettingsViewModel.swift
+++ b/Hutch/Views/Settings/SettingsViewModel.swift
@@ -198,9 +198,9 @@ final class SettingsViewModel {
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
+ "url": url.isEmpty ? nil as String? as any Sendable : url,
+ "location": location.isEmpty ? nil as String? as any Sendable : location,
+ "bio": bio.isEmpty ? nil as String? as any Sendable : bio
]
let result = try await client.execute(
service: .meta,
@@ -243,7 +243,7 @@ final class SettingsViewModel {
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 input: [String: any Sendable] = ["avatar": nil as String? as any Sendable]
let result = try await client.executeMultipart(
service: .meta,
query: Self.updateUserMutation,
@@ -286,7 +286,7 @@ final class SettingsViewModel {
error = nil
do {
- let input: [String: any Sendable] = ["avatar": nil as String? as Any]
+ let input: [String: any Sendable] = ["avatar": nil as String? as any Sendable]
let result = try await client.execute(
service: .meta,
query: Self.updateUserMutation,