From c4930f31ffc5d7c5de5eeafd3da184c7691f8ab7 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 15 Jul 2026 23:50:18 -0500 Subject: feat: upload and delete repository artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uploadArtifact and deleteArtifact existed in git.sr.ht's API but were never called, so the artifacts tab could only download. Upload is reachable two ways, and the second is the one that matters: the tab only lists tags that already carry an artifact, so a per-section button alone could never attach the first one to a tag — and the app cannot create that first artifact any other way. A toolbar action picks from all tags instead. The file variable is top-level here, unlike meta's avatar upload where it nests inside an input object. This is the second caller of executeMultipart, which until now only served avatars. Artifacts are tarballs and signatures, so the upload declares application/octet-stream rather than guessing a type from the extension. Security-scoped access is released after the read, since fileImporter hands back a URL the app does not otherwise own. Both actions are gated on repository ownership, reusing the check RepositoryDetailView already applies to its other management surfaces rather than recomputing it. Delete sits behind a confirmation naming the file. --- Hutch/Views/Repositories/ArtifactsView.swift | 96 ++++++++++++++++- .../Views/Repositories/RepositoryDetailView.swift | 2 +- .../Repositories/RepositoryDetailViewModel.swift | 120 +++++++++++++++++++++ 3 files changed, 216 insertions(+), 2 deletions(-) diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index b8caf4c..264a51e 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -1,24 +1,118 @@ import SwiftUI +import UniformTypeIdentifiers struct ArtifactsView: View { let viewModel: RepositoryDetailViewModel + /// Passed in rather than recomputed: RepositoryDetailView already owns this + /// check and gates its other management surfaces on it. + var canManage: Bool = false @Environment(\.openURL) private var openURL + @State private var uploadTargetRef: String? + @State private var pendingDeletion: ArtifactInfo? + @State private var showTagPicker = false + + private var isOwnedByCurrentUser: Bool { canManage } + var body: some View { List { ForEach(viewModel.referenceArtifacts) { refArtifacts in - Section(refArtifacts.name) { + Section { ForEach(refArtifacts.artifacts) { artifact in ArtifactRow(artifact: artifact) { openURL(artifact.url) } + .swipeActions(edge: .trailing) { + if isOwnedByCurrentUser { + Button(role: .destructive) { + pendingDeletion = artifact + } label: { + SwiftUI.Label("Delete", systemImage: "trash") + } + } + } } .themedRow() + } header: { + HStack { + Text(refArtifacts.name) + if isOwnedByCurrentUser { + Spacer() + // Upload targets a specific tag, so the control belongs + // on the tag rather than in the toolbar. + Button { + uploadTargetRef = refArtifacts.name + } label: { + SwiftUI.Label("Upload", systemImage: "plus.circle") + .font(.caption) + } + .disabled(viewModel.isMutatingArtifact) + } + } } } } + .fileImporter( + isPresented: .init( + get: { uploadTargetRef != nil }, + set: { if !$0 { uploadTargetRef = nil } } + ), + allowedContentTypes: [.data] + ) { result in + guard let revspec = uploadTargetRef else { return } + uploadTargetRef = nil + if case .success(let fileURL) = result { + Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) } + } + } + .confirmationDialog( + pendingDeletion.map { "Delete \($0.filename)?" } ?? "", + isPresented: .init( + get: { pendingDeletion != nil }, + set: { if !$0 { pendingDeletion = nil } } + ), + titleVisibility: .visible, + presenting: pendingDeletion + ) { artifact in + Button("Delete Artifact", role: .destructive) { + Task { await viewModel.deleteArtifact(id: artifact.id) } + } + Button("Cancel", role: .cancel) { pendingDeletion = nil } + } message: { _ in + Text("This permanently removes the artifact from the tag. This cannot be undone.") + } + // The sections above only list tags that already have an artifact, so + // without this there would be no way to attach the first one to a tag. + .toolbar { + if isOwnedByCurrentUser { + ToolbarItem(placement: .topBarTrailing) { + Button { + showTagPicker = true + } label: { + SwiftUI.Label("Upload Artifact", systemImage: "square.and.arrow.up") + } + .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) + } + } + } + .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) { + ForEach(viewModel.tags.prefix(12), id: \.name) { tag in + Button(RepositorySummary.displayBranchName(for: tag.name)) { + uploadTargetRef = tag.name + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Artifacts attach to a tag. Filenames must be unique within the repository.") + } .themedList() .listStyle(.insetGrouped) + .task { + // Tags drive the picker above and are not otherwise needed by this tab. + if isOwnedByCurrentUser, viewModel.tags.isEmpty { + await viewModel.loadReferences() + } + } .overlay { if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty { SRHTLoadingStateView(message: "Loading artifacts…") diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift index 6e7343f..8f466ba 100644 --- a/Hutch/Views/Repositories/RepositoryDetailView.swift +++ b/Hutch/Views/Repositories/RepositoryDetailView.swift @@ -121,7 +121,7 @@ struct RepositoryDetailView: View { case .refs: ReferencesListView(viewModel: viewModel) case .artifacts: - ArtifactsView(viewModel: viewModel) + ArtifactsView(viewModel: viewModel, canManage: canManageRepository) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift index 4839437..dce39c1 100644 --- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -75,6 +75,20 @@ private struct PathObject: Decodable, Sendable { let text: String? } +private struct UploadArtifactResponse: Decodable, Sendable { + let uploadArtifact: ArtifactInfo +} + +private struct DeleteArtifactResponse: Decodable, Sendable { + /// Nullable in the schema: sr.ht returns null when there was no artifact to + /// remove, which is still a success from the caller's point of view. + let deleteArtifact: ArtifactIDPayload? +} + +private struct ArtifactIDPayload: Decodable, Sendable { + let id: Int +} + private struct ArtifactsResponse: Decodable, Sendable { let repository: ArtifactsRepository? } @@ -144,6 +158,7 @@ final class RepositoryDetailViewModel { private(set) var referenceArtifacts: [ReferenceWithArtifacts] = [] private(set) var isLoadingArtifacts = false + private(set) var isMutatingArtifact = false // MARK: - Error @@ -457,6 +472,26 @@ final class RepositoryDetailViewModel { // MARK: - Artifacts + /// `file` is a top-level Upload variable here, unlike meta's avatar upload + /// where it is nested inside an input object. + private static let uploadArtifactMutation = """ + mutation uploadArtifact($repoId: Int!, $revspec: String!, $file: Upload!) { + uploadArtifact(repoId: $repoId, revspec: $revspec, file: $file) { + id + filename + checksum + size + url + } + } + """ + + private static let deleteArtifactMutation = """ + mutation deleteArtifact($id: Int!) { + deleteArtifact(id: $id) { id } + } + """ + private static let artifactsQuery = """ query artifacts($rid: ID!) { repository(rid: $rid) { @@ -480,6 +515,91 @@ final class RepositoryDetailViewModel { } """ + /// Attaches a file to the tag named by `revspec`. + /// + /// sr.ht requires the filename to be unique among the repository's artifacts, + /// and rejects a duplicate rather than replacing it, so the error is surfaced + /// as-is rather than being retried. + @discardableResult + func uploadArtifact(revspec: String, fileURL: URL) async -> Bool { + guard !isMutatingArtifact else { return false } + isMutatingArtifact = true + error = nil + defer { isMutatingArtifact = false } + + let needsScopedAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if needsScopedAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + let fileData: Data + do { + fileData = try Data(contentsOf: fileURL) + } catch { + self.error = "Couldn't read \(fileURL.lastPathComponent)." + return false + } + + do { + _ = try await client.executeMultipart( + service: service, + query: Self.uploadArtifactMutation, + variables: [ + "repoId": repository.id, + "revspec": revspec, + "file": nil as String? as Any + ], + file: MultipartUploadFile( + variablePath: "file", + fileData: fileData, + fileName: fileURL.lastPathComponent, + mimeType: Self.mimeType(for: fileURL) + ), + responseType: UploadArtifactResponse.self + ) + await reloadArtifacts() + return true + } catch { + self.error = "Couldn't upload \(fileURL.lastPathComponent). \(error.userFacingMessage)" + return false + } + } + + @discardableResult + func deleteArtifact(id: Int) async -> Bool { + guard !isMutatingArtifact else { return false } + isMutatingArtifact = true + error = nil + defer { isMutatingArtifact = false } + + do { + _ = try await client.execute( + service: service, + query: Self.deleteArtifactMutation, + variables: ["id": id], + responseType: DeleteArtifactResponse.self + ) + await reloadArtifacts() + return true + } catch { + self.error = "Couldn't delete the artifact. \(error.userFacingMessage)" + return false + } + } + + private func reloadArtifacts() async { + isLoadingArtifacts = false + await loadArtifacts() + } + + /// Artifacts are release tarballs and signatures rather than media, so a + /// generic binary type is honest more often than guessing from the extension. + private nonisolated static func mimeType(for url: URL) -> String { + "application/octet-stream" + } + func loadArtifacts() async { guard !isLoadingArtifacts else { return } isLoadingArtifacts = true -- cgit v1.2.3 From f3627deef0848b95a083d9e16468b0f8484d784e Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 15 Jul 2026 23:52:45 -0500 Subject: feat: show the meta.sr.ht audit log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auditLog existed in the API but was never called, so the record of what has happened to your account — logins, key changes, the addresses they came from — was web-only. Sits under the tokens in Profile and loads on demand for the same reason they do: an audit log is something you go looking for, not something worth a request on every profile view. One page, newest first; the archive stays on meta.sr.ht. Its errors are kept separate from the shared `error` so a failed audit fetch cannot bury a profile save failure, and vice versa. --- Hutch/Models/Meta.swift | 10 +++++ Hutch/Views/More/ProfileView.swift | 56 ++++++++++++++++++++++++++++ Hutch/Views/Settings/SettingsViewModel.swift | 42 +++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift index 91bd7e3..f52fc76 100644 --- a/Hutch/Models/Meta.swift +++ b/Hutch/Models/Meta.swift @@ -69,3 +69,13 @@ struct PersonalAccessToken: Codable, Sendable, Identifiable { let comment: String? let grants: String? } + +/// One entry in meta.sr.ht's audit log: a security-relevant action on the +/// account, with the address it came from. +struct AuditLogEntry: Codable, Sendable, Identifiable { + let id: Int + let created: Date + let ipAddress: String + let eventType: String + let details: String? +} diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift index 45f7d06..fc1ed2f 100644 --- a/Hutch/Views/More/ProfileView.swift +++ b/Hutch/Views/More/ProfileView.swift @@ -84,6 +84,7 @@ struct ProfileView: View { sshKeysSection(viewModel) pgpKeysSection(viewModel) patSection(viewModel) + auditLogSection(viewModel) } } .themedList() @@ -432,6 +433,61 @@ struct ProfileView: View { } } } + + /// Loaded on demand, like the tokens above — an audit log is something you go + /// looking for, not something worth a request on every profile view. + @ViewBuilder + private func auditLogSection(_ viewModel: SettingsViewModel) -> some View { + Section { + if viewModel.isLoadingAuditLog { + HStack { + Spacer() + ProgressView() + Spacer() + } + .themedRow() + } else if let error = viewModel.auditLogError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .themedRow() + } else if viewModel.auditLog.isEmpty { + Button("Load Audit Log") { + Task { await viewModel.loadAuditLog() } + } + .themedRow() + } else { + ForEach(viewModel.auditLog) { entry in + VStack(alignment: .leading, spacing: 4) { + Text(entry.eventType) + .font(.subheadline) + + if let details = entry.details, !details.isEmpty { + Text(details) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + HStack(spacing: 12) { + Text(entry.created.relativeDescription) + Text(entry.ipAddress) + .monospaced() + } + .font(.caption2) + .foregroundStyle(.tertiary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(entry.eventType), \(entry.created.relativeDescription), from \(entry.ipAddress)") + } + .themedRow() + } + } header: { + Text("Audit Log") + } footer: { + Text("Recent security-relevant activity on your account, newest first. The full log lives on meta.sr.ht.") + } + } } private struct ProfileBioView: View { diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift index edfaad4..8536e60 100644 --- a/Hutch/Views/Settings/SettingsViewModel.swift +++ b/Hutch/Views/Settings/SettingsViewModel.swift @@ -43,6 +43,15 @@ private struct PATListResponse: Decodable, Sendable { let personalAccessTokens: [PersonalAccessToken] } +private struct AuditLogResponse: Decodable, Sendable { + let auditLog: AuditLogPage +} + +private struct AuditLogPage: Decodable, Sendable { + let results: [AuditLogEntry] + let cursor: String? +} + // MARK: - View Model @Observable @@ -53,6 +62,11 @@ final class SettingsViewModel { private(set) var sshKeys: [SSHKey] = [] private(set) var pgpKeys: [PGPKey] = [] private(set) var personalAccessTokens: [PersonalAccessToken] = [] + private(set) var auditLog: [AuditLogEntry] = [] + private(set) var isLoadingAuditLog = false + /// Kept apart from `error` so a failed audit fetch cannot bury a profile + /// save failure, and vice versa. + var auditLogError: String? private(set) var isLoading = false private(set) var isLoadingPATs = false @@ -139,6 +153,12 @@ final class SettingsViewModel { } """ + private static let auditLogQuery = """ + query auditLog { + auditLog { results { id created ipAddress eventType details } } + } + """ + private static let personalAccessTokensQuery = """ query personalAccessTokens { personalAccessTokens { id issued expires comment grants } @@ -393,4 +413,26 @@ final class SettingsViewModel { isLoadingPATs = false } + // MARK: - Audit Log + + /// Loads the most recent audit entries. + /// + /// Deliberately one page: this is a glanceable "has anything happened to my + /// account" surface, not an archive. The full log is on meta.sr.ht. + func loadAuditLog() async { + guard !isLoadingAuditLog else { return } + isLoadingAuditLog = true + defer { isLoadingAuditLog = false } + + do { + let result = try await client.execute( + service: .meta, + query: Self.auditLogQuery, + responseType: AuditLogResponse.self + ) + auditLog = result.auditLog.results + } catch { + auditLogError = error.userFacingMessage + } + } } -- cgit v1.2.3 From 59622556deb4de7843faa493c8d5a6ef66591d91 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 15 Jul 2026 23:58:26 -0500 Subject: feat: create, edit, and delete mailing lists createMailingList, updateMailingList, and deleteMailingList existed in the API but were never called, so lists could only be read. Editing needed a read first. InboxMailingListReference carries only id/rid/name/owner, so a settings sheet seeded from it would have offered an empty description and Public visibility, and saving would have blanked the real description and quietly changed who can see the list. The sheet now reads the current values and refuses to save until it has them. Clearing a description sends an explicit null via updateValue rather than a nil subscript assignment, which would drop the key and leave the old text in place. permitMime and rejectMime are left untouched rather than sent empty, which would wipe the list's mime filters. Edit and delete are gated on ownership: the subscriptions query that builds this view returns lists the user follows, which is not the same as lists they own. Non-owners keep the unsubscribe action instead. Deleting destroys the archive for everyone, so the confirmation says exactly that. --- Hutch/Views/Lists/MailingListListView.swift | 361 +++++++++++++++++++++++++++- 1 file changed, 356 insertions(+), 5 deletions(-) diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift index 09fa2a0..cc811df 100644 --- a/Hutch/Views/Lists/MailingListListView.swift +++ b/Hutch/Views/Lists/MailingListListView.swift @@ -1,5 +1,9 @@ import SwiftUI +private struct ListIDPayload: Decodable, Sendable { + let id: Int +} + @Observable @MainActor final class MailingListListViewModel { @@ -35,10 +39,176 @@ final class MailingListListViewModel { } """ + private static let createMailingListMutation = """ + mutation createMailingList($name: String!, $description: String, $visibility: Visibility!) { + createMailingList(name: $name, description: $description, visibility: $visibility) { + id + rid + name + owner { canonicalName } + } + } + """ + + /// InboxMailingListReference carries only id/rid/name/owner, so the settings + /// sheet has to read the current values before it can offer to change them — + /// otherwise saving would blank the description and reset visibility. + private static let listSettingsQuery = """ + query listSettings($rid: ID!) { + list(rid: $rid) { + description + visibility + } + } + """ + + private static let updateMailingListMutation = """ + mutation updateMailingList($id: Int!, $input: MailingListInput!) { + updateMailingList(id: $id, input: $input) { id } + } + """ + + private static let deleteMailingListMutation = """ + mutation deleteMailingList($id: Int!) { + deleteMailingList(id: $id) { id } + } + """ + init(client: SRHTClient) { self.client = client } + /// Creates a list. sr.ht subscribes the owner automatically, so a reload is + /// enough to surface it — this view is built from the subscriptions query. + @discardableResult + func createMailingList(name: String, description: String, visibility: Visibility) async -> Bool { + guard !isPerformingAction else { return false } + isPerformingAction = true + error = nil + defer { isPerformingAction = false } + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) + + do { + struct Response: Decodable, Sendable { + let createMailingList: InboxMailingListReference + } + + _ = try await client.execute( + service: .lists, + query: Self.createMailingListMutation, + variables: [ + "name": trimmedName, + "description": trimmedDescription.isEmpty ? nil as String? as Any : trimmedDescription, + "visibility": visibility.rawValue + ], + responseType: Response.self + ) + await loadMailingLists() + return true + } catch { + self.error = "Couldn't create \(trimmedName). \(error.userFacingMessage)" + return false + } + } + + /// Reads a list's current description and visibility, so the settings sheet + /// can seed itself rather than overwrite with blanks. + func listSettings(rid: String) async -> (description: String, visibility: Visibility)? { + struct Response: Decodable, Sendable { + let list: ListSettingsPayload? + } + + struct ListSettingsPayload: Decodable, Sendable { + let description: String? + let visibility: Visibility + } + + do { + let response = try await client.execute( + service: .lists, + query: Self.listSettingsQuery, + variables: ["rid": rid], + responseType: Response.self + ) + guard let list = response.list else { return nil } + return (list.description ?? "", list.visibility) + } catch { + self.error = "Couldn't load the list's settings. \(error.userFacingMessage)" + return nil + } + } + + /// Edits a list's description and visibility. + /// + /// `MailingListInput` also carries `permitMime` / `rejectMime`; those are left + /// alone rather than sent as empty, which would clear the list's filters. + @discardableResult + func updateMailingList(id: Int, description: String, visibility: Visibility) async -> Bool { + guard !isPerformingAction else { return false } + isPerformingAction = true + error = nil + defer { isPerformingAction = false } + + let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) + var input: [String: any Sendable] = ["visibility": visibility.rawValue] + if trimmedDescription.isEmpty { + // A nil subscript assignment would drop the key and leave the old + // description in place instead of clearing it. + input.updateValue(Optional.none as any Sendable, forKey: "description") + } else { + input["description"] = trimmedDescription + } + + do { + struct Response: Decodable, Sendable { + let updateMailingList: ListIDPayload? + } + + _ = try await client.execute( + service: .lists, + query: Self.updateMailingListMutation, + variables: ["id": id, "input": input], + responseType: Response.self + ) + await loadMailingLists() + return true + } catch { + self.error = "Couldn't update the list. \(error.userFacingMessage)" + return false + } + } + + @discardableResult + func deleteMailingList(_ mailingList: InboxMailingListReference) async -> Bool { + guard !isPerformingAction else { return false } + isPerformingAction = true + error = nil + defer { isPerformingAction = false } + + let previousLists = mailingLists + mailingLists.removeAll { $0.rid == mailingList.rid } + + do { + struct Response: Decodable, Sendable { + let deleteMailingList: ListIDPayload? + } + + _ = try await client.execute( + service: .lists, + query: Self.deleteMailingListMutation, + variables: ["id": mailingList.id], + responseType: Response.self + ) + return true + } catch { + mailingLists = previousLists + self.error = "Couldn't delete \(mailingList.name). \(error.userFacingMessage)" + return false + } + } + /// Unsubscribes from a list and drops it from the list on success. This view /// is built from the subscriptions query, so a successful unsubscribe means /// the row no longer belongs here. @@ -149,6 +319,19 @@ struct MailingListListView: View { @Environment(AppState.self) private var appState @State private var viewModel: MailingListListViewModel? @State private var pendingUnsubscribe: InboxMailingListReference? + @State private var pendingDeletion: InboxMailingListReference? + @State private var editingList: InboxMailingListReference? + @State private var showCreateSheet = false + + /// The subscriptions query returns lists the user follows, which is not the + /// same as lists they own — only the owner may edit or delete one. + private func isOwned(_ mailingList: InboxMailingListReference) -> Bool { + guard let currentUser = appState.currentUser else { return false } + let owner = mailingList.owner.canonicalName.hasPrefix("~") + ? String(mailingList.owner.canonicalName.dropFirst()) + : mailingList.owner.canonicalName + return owner.caseInsensitiveCompare(currentUser.username) == .orderedSame + } var body: some View { Group { @@ -185,12 +368,26 @@ struct MailingListListView: View { .padding(.vertical, 2) } .swipeActions(edge: .trailing) { - Button { - pendingUnsubscribe = mailingList - } label: { - SwiftUI.Label("Unsubscribe", systemImage: "bell.slash") + if isOwned(mailingList) { + Button(role: .destructive) { + pendingDeletion = mailingList + } label: { + SwiftUI.Label("Delete", systemImage: "trash") + } + Button { + editingList = mailingList + } label: { + SwiftUI.Label("Settings", systemImage: "gear") + } + .tint(.gray) + } else { + Button { + pendingUnsubscribe = mailingList + } label: { + SwiftUI.Label("Unsubscribe", systemImage: "bell.slash") + } + .tint(.orange) } - .tint(.orange) } } .themedRow() @@ -218,6 +415,46 @@ struct MailingListListView: View { } message: { _ in Text("You will stop receiving email from this list. Hutch cannot resubscribe you — you would need to do that from the list's page on the web.") } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showCreateSheet = true + } label: { + SwiftUI.Label("New List", systemImage: "plus") + } + .disabled(viewModel.isPerformingAction) + } + } + .sheet(isPresented: $showCreateSheet) { + MailingListEditSheet(mode: .create, isPresented: $showCreateSheet) { name, description, visibility in + await viewModel.createMailingList(name: name, description: description, visibility: visibility) + } + } + .sheet(item: $editingList) { mailingList in + MailingListEditSheet( + mode: .edit(mailingList.name), + isPresented: .init(get: { true }, set: { if !$0 { editingList = nil } }), + loadInitialValues: { await viewModel.listSettings(rid: mailingList.rid) } + ) { _, description, visibility in + await viewModel.updateMailingList(id: mailingList.id, description: description, visibility: visibility) + } + } + .confirmationDialog( + pendingDeletion.map { "Delete \($0.name)?" } ?? "", + isPresented: .init( + get: { pendingDeletion != nil }, + set: { if !$0 { pendingDeletion = nil } } + ), + titleVisibility: .visible, + presenting: pendingDeletion + ) { mailingList in + Button("Delete List", role: .destructive) { + Task { await viewModel.deleteMailingList(mailingList) } + } + Button("Cancel", role: .cancel) { pendingDeletion = nil } + } message: { _ in + Text("This permanently deletes the list and its entire archive, for everyone. This cannot be undone.") + } .overlay { if viewModel.isLoading, viewModel.mailingLists.isEmpty { SRHTLoadingStateView(message: "Loading mailing lists…") @@ -243,3 +480,117 @@ struct MailingListListView: View { } } } + +// MARK: - Edit Sheet + +/// Create and settings share a sheet: sr.ht takes name only at creation, and +/// description plus visibility in both cases. +private struct MailingListEditSheet: View { + enum Mode { + case create + case edit(String) + + var title: String { + switch self { + case .create: "New Mailing List" + case .edit(let name): name + } + } + + var isCreate: Bool { + if case .create = self { return true } + return false + } + } + + let mode: Mode + @Binding var isPresented: Bool + /// Seeds the sheet with the list's current values. Editing without this would + /// save blanks over whatever is already there. + var loadInitialValues: (() async -> (description: String, visibility: Visibility)?)? + let onSubmit: (String, String, Visibility) async -> Bool + + @State private var name = "" + @State private var description = "" + @State private var visibility: Visibility = .publicVisibility + @State private var isSubmitting = false + @State private var isLoadingInitialValues = false + @State private var hasLoadedInitialValues = false + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmit: Bool { + guard !isSubmitting, !isLoadingInitialValues else { return false } + if mode.isCreate { return !trimmedName.isEmpty } + // Never offer to save values we have not read back yet. + return hasLoadedInitialValues + } + + var body: some View { + NavigationStack { + Form { + if mode.isCreate { + Section("Name") { + TextField("list-name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .themedRow() + } + } + + Section("Description") { + TextField("Description", text: $description, axis: .vertical) + .lineLimit(2...6) + .themedRow() + } + + Section("Visibility") { + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.publicVisibility) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.privateVisibility) + } + .pickerStyle(.inline) + .labelsHidden() + .themedRow() + } + } + .themedList() + .navigationTitle(mode.title) + .navigationBarTitleDisplayMode(.inline) + .task { + guard let loadInitialValues, !hasLoadedInitialValues else { return } + isLoadingInitialValues = true + if let current = await loadInitialValues() { + description = current.description + visibility = current.visibility + hasLoadedInitialValues = true + } + isLoadingInitialValues = false + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { isPresented = false } + } + ToolbarItem(placement: .confirmationAction) { + Button(mode.isCreate ? "Create" : "Save") { + Task { + isSubmitting = true + let ok = await onSubmit(trimmedName, description, visibility) + isSubmitting = false + if ok { isPresented = false } + } + } + .disabled(!canSubmit) + } + } + .overlay { + if isSubmitting || isLoadingInitialValues { + ProgressView() + } + } + } + } +} -- cgit v1.2.3 From 8ee93a6a6a0e771b687d1f48a59fb896ae0456e5 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 00:00:52 -0500 Subject: feat: add a ticket activity feed todo.sr.ht's root events query returns what the authenticated user is subscribed to or implicated in, newest first, across every tracker including ones they do not own. It was never called, so the only way to notice a reply was to open the ticket. Reuses EventChange, which already decodes todo's polymorphic EventDetail for ticket timelines, so the same inline fragments describe both surfaces. Rows push straight to the ticket. events is nullable and comes back null when the token lacks the EVENTS scope, so that case reports a scope problem rather than an empty feed, which would read as "nothing has happened". Paginates rather than fetching everything: an active account's history is unbounded and the top of it is the whole point. archiveMessage, the other half of this roadmap item, is not here. It is marked @internal in the schema and inaccessible, like revokePersonalAccessToken already recorded in SCOPE.md. --- Hutch/App/RootView.swift | 3 + Hutch/Views/Activity/ActivityView.swift | 96 ++++++++++++ Hutch/Views/Activity/ActivityViewModel.swift | 210 +++++++++++++++++++++++++++ Hutch/Views/Lookup/LookupView.swift | 2 + Hutch/Views/More/MoreView.swift | 7 + 5 files changed, 318 insertions(+) create mode 100644 Hutch/Views/Activity/ActivityView.swift create mode 100644 Hutch/Views/Activity/ActivityViewModel.swift diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 1ae4651..32d1624 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -439,6 +439,7 @@ enum MoreRoute: Hashable { case projectDashboard(id: String, title: String?) case mailingList(InboxMailingListReference) case thread(InboxThreadSummary) + case activity case manPageBrowser case manPage(URL) } @@ -472,6 +473,8 @@ private struct MoreNavigationRoot: View { ProjectDashboardDeepLinkView(projectID: id, title: title) case .mailingList(let mailingList): MailingListDetailView(mailingList: mailingList) + case .activity: + ActivityView() case .thread(let thread): ThreadDetailView( thread: thread, diff --git a/Hutch/Views/Activity/ActivityView.swift b/Hutch/Views/Activity/ActivityView.swift new file mode 100644 index 0000000..6379d45 --- /dev/null +++ b/Hutch/Views/Activity/ActivityView.swift @@ -0,0 +1,96 @@ +import SwiftUI + +struct ActivityView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: ActivityViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading Activity…") + } + } + .navigationTitle("Activity") + .navigationBarTitleDisplayMode(.inline) + .task { + let model = viewModel ?? ActivityViewModel(client: appState.client) + viewModel = model + await model.loadIfNeeded() + } + } + + @ViewBuilder + private func content(_ viewModel: ActivityViewModel) -> some View { + List { + ForEach(viewModel.events) { event in + NavigationLink { + TicketDetailView( + ownerUsername: event.ownerUsername, + trackerName: event.trackerName, + trackerId: event.trackerID, + trackerRid: event.trackerRID, + ticketId: event.ticketID + ) + } label: { + ActivityRow(event: event) + } + .themedRow() + } + + if viewModel.hasMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .themedRow() + .task { await viewModel.loadMore() } + } + } + .themedList() + .listStyle(.plain) + .refreshable { await viewModel.load() } + .overlay { + if viewModel.isLoading, viewModel.events.isEmpty { + SRHTLoadingStateView(message: "Loading Activity…") + } else if let error = viewModel.error, viewModel.events.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Activity", + message: error, + retryAction: { await viewModel.load() } + ) + } else if viewModel.events.isEmpty { + ContentUnavailableView( + "No Activity", + systemImage: "bell", + description: Text("Ticket activity you are subscribed to or involved in appears here.") + ) + } + } + } +} + +private struct ActivityRow: View { + let event: ActivityEvent + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(event.ticketSubject) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + + Text("\(event.summary) • \(event.created.relativeDescription)") + .font(.caption) + .foregroundStyle(.secondary) + + Text("\(event.trackerOwner.canonicalName)/\(event.trackerName) #\(event.ticketID)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(event.ticketSubject), \(event.summary), \(event.created.relativeDescription)") + } +} diff --git a/Hutch/Views/Activity/ActivityViewModel.swift b/Hutch/Views/Activity/ActivityViewModel.swift new file mode 100644 index 0000000..77e500d --- /dev/null +++ b/Hutch/Views/Activity/ActivityViewModel.swift @@ -0,0 +1,210 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct ActivityResponse: Decodable, Sendable { + /// Nullable in the schema, and null when the token lacks the EVENTS scope. + let events: ActivityPage? +} + +private struct ActivityPage: Decodable, Sendable { + let results: [ActivityEventPayload] + let cursor: String? +} + +private struct ActivityEventPayload: Decodable, Sendable { + let id: Int + let created: Date + let changes: [EventChange] + let ticket: ActivityTicketPayload +} + +private struct ActivityTicketPayload: Decodable, Sendable { + let id: Int + let subject: String + let tracker: ActivityTrackerPayload +} + +private struct ActivityTrackerPayload: Decodable, Sendable { + let id: Int + let rid: String + let name: String + let owner: Entity +} + +// MARK: - View Model + +/// The authenticated user's ticket activity across every tracker. +/// +/// todo.sr.ht's root `events` returns what the user is subscribed to or +/// implicated in, newest first — the closest thing sr.ht offers to a personal +/// feed, and it works across trackers the user does not own. +@Observable +@MainActor +final class ActivityViewModel { + + private(set) var events: [ActivityEvent] = [] + private(set) var isLoading = false + private(set) var isLoadingMore = false + private(set) var hasMore = false + var error: String? + + private var cursor: String? + private let client: SRHTClient + + init(client: SRHTClient) { + self.client = client + } + + private static let eventsQuery = """ + query activity($cursor: Cursor) { + events(cursor: $cursor) { + results { + id + created + changes { + eventType: __typename + ... on Created { __typename } + ... on Comment { + author { canonicalName } + text + authenticity + } + ... on StatusChange { + oldStatus + newStatus + } + ... on LabelUpdate { + labeler { canonicalName } + label { name } + } + ... on Assignment { + assigner { canonicalName } + assignee { canonicalName } + } + } + ticket { + id + subject + tracker { id rid name owner { canonicalName } } + } + } + cursor + } + } + """ + + func loadIfNeeded() async { + guard events.isEmpty, !isLoading else { return } + await load() + } + + func load() async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + cursor = nil + do { + let page = try await fetch(cursor: nil) + events = page.events + cursor = page.cursor + hasMore = page.cursor != nil + } catch { + self.error = error.userFacingMessage + } + } + + func loadMore() async { + guard !isLoadingMore, !isLoading, let cursor else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let page = try await fetch(cursor: cursor) + events.append(contentsOf: page.events) + self.cursor = page.cursor + hasMore = page.cursor != nil + } catch { + // Keep what is already on screen; the next scroll can retry. + self.error = error.userFacingMessage + } + } + + private func fetch(cursor: String?) async throws -> (events: [ActivityEvent], cursor: String?) { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .todo, + query: Self.eventsQuery, + variables: variables.isEmpty ? nil : variables, + responseType: ActivityResponse.self + ) + + guard let page = response.events else { + throw SRHTError.graphQLErrors([ + GraphQLError(message: "Your token does not grant access to ticket events.", locations: nil) + ]) + } + + let mapped = page.results.map { payload in + ActivityEvent( + id: payload.id, + created: payload.created, + changes: payload.changes, + ticketID: payload.ticket.id, + ticketSubject: payload.ticket.subject, + trackerID: payload.ticket.tracker.id, + trackerRID: payload.ticket.tracker.rid, + trackerName: payload.ticket.tracker.name, + trackerOwner: payload.ticket.tracker.owner + ) + } + return (mapped, page.cursor) + } +} + +/// One entry in the activity feed, flattened so the row does not have to walk +/// into the ticket and tracker payloads. +struct ActivityEvent: Identifiable, Sendable { + let id: Int + let created: Date + let changes: [EventChange] + let ticketID: Int + let ticketSubject: String + let trackerID: Int + let trackerRID: String + let trackerName: String + let trackerOwner: Entity + + var ownerUsername: String { + trackerOwner.canonicalName.hasPrefix("~") + ? String(trackerOwner.canonicalName.dropFirst()) + : trackerOwner.canonicalName + } + + /// A one-line description of what happened, from the first change. + var summary: String { + guard let change = changes.first else { return "Updated" } + switch change.eventType { + case "Created": return "Filed" + case "Comment": return "Commented" + case "StatusChange": + if let newStatus = change.newStatus { + return "Status \(newStatus.displayName.lowercased())" + } + return "Status changed" + case "LabelUpdate": return change.label.map { "Labeled \($0.name)" } ?? "Labels changed" + case "Assignment": + if let assignee = change.assignee { + return "Assigned \(assignee.canonicalName)" + } + return "Assignment changed" + default: return "Updated" + } + } +} diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift index 2a26282..3b35325 100644 --- a/Hutch/Views/Lookup/LookupView.swift +++ b/Hutch/Views/Lookup/LookupView.swift @@ -465,6 +465,8 @@ struct LookupView: View { ProjectDashboardDeepLinkView(projectID: id, title: title) case .mailingList(let mailingList): MailingListDetailView(mailingList: mailingList) + case .activity: + ActivityView() case .thread(let thread): ThreadDetailView( thread: thread, diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index ad02077..eda918f 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -19,6 +19,13 @@ struct MoreView: View { .themedRow() } + Section("Activity") { + NavigationLink(value: MoreRoute.activity) { + Label("Ticket Activity", systemImage: "bell.badge") + } + .themedRow() + } + Section("Other Services") { NavigationLink(value: MoreRoute.projects) { Label("Projects", systemImage: "square.stack.3d.up") -- cgit v1.2.3 From 76cb006b87d30928ec23a3c4bf95bd145aad8e9c Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 00:03:06 -0500 Subject: chore: bump to 3.8.0 and record Phase 3 API features MARKETING_VERSION 3.7.0 -> 3.8.0, build 89 -> 90. SCOPE.md gains the items that did not survive contact with the API: archiveMessage and mailingListSubscribe are blocked, while webhooks, shareSecret, and build groups are reachable but declined on judgement. The reasoning is recorded so they do not get re-proposed as gaps. ROADMAP.md notes that Phase 3 is several releases rather than one, with the measured size of each. --- Hutch.xcodeproj/project.pbxproj | 24 ++++++++++---------- README.md | 5 ++++- ROADMAP.md | 50 ++++++++++++++++++++++++++++------------- SCOPE.md | 21 +++++++++++++++++ 4 files changed, 71 insertions(+), 29 deletions(-) diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 8f71248..32da475 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -597,7 +597,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -614,7 +614,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -634,7 +634,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -651,7 +651,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -714,7 +714,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -724,7 +724,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -743,7 +743,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -753,7 +753,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -772,7 +772,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchSafariExtension/Info.plist; @@ -782,7 +782,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -801,7 +801,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchSafariExtension/Info.plist; @@ -811,7 +811,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/README.md b/README.md index fd553a8..721d456 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,16 @@ The app currently includes: - Home dashboard with assigned tickets, recent builds, and projects - Repository browsing for Git and Mercurial repositories - Repository details including README, references, commits, diffs, files, artifacts, and settings +- Artifact upload and deletion on release tags - Tracker and ticket browsing, ticket detail views, and tracker creation - Ticket editing and deletion, with subscriptions for tickets and trackers - Build job browsing, build detail views, and build submission - Inbox and mailing list reading flows - Patchset review: cover letters, per-patch diffs, checks, version chains, and status changes +- Mailing list creation, settings, and deletion +- Ticket activity feed across every tracker you follow - Paste browsing, creation, and detail views -- Profile and account settings, including SSH keys, PGP keys, and personal access token management +- Profile and account settings, including SSH keys, PGP keys, personal access token management, and the audit log - Email preferences for todo.sr.ht and lists.sr.ht - Deep links for repositories, tickets, and build jobs diff --git a/ROADMAP.md b/ROADMAP.md index f08981d..7c64af3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,22 +131,40 @@ GraphQL mutation. Treat that boundary as explicit rather than half-building it. ## Phase 3: Polish and reach -- **Localization.** The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS = - YES` but ships no string catalog, so every user-facing string is hardcoded - English. -- **Accessibility.** Labels and hints appear in only 16 of roughly 130 view - files. -- `uploadArtifact` / `deleteArtifact` — artifacts are read-only today. -- Webhook management. Zero calls to any `create*Webhook` across every service. - Push notifications are out of scope because they need a relay server (see - [SCOPE.md](SCOPE.md)), but webhook management is client-side only and is a - prerequisite if that relay ever ships. -- `auditLog` (meta.sr.ht) — unused security surface. -- Build groups (`createGroup`, `startGroup`) and secret management - (`shareSecret`, the `secrets` query). Today `secrets` is only a submit toggle. -- Mailing list creation and settings (`createMailingList`, `updateMailingList`, - `deleteMailingList`). -- `events` feed (todo.sr.ht) and `archiveMessage` (lists.sr.ht). +Unlike Phases 1 and 2, this is not one shippable thing. It is several, and they +are sized very differently — measure before committing to one. + +### API features — done (v3.8.0) + +- ~~`uploadArtifact` / `deleteArtifact`~~ — artifacts were read-only. +- ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile. +- ~~Mailing list creation and settings~~ (`createMailingList`, + `updateMailingList`, `deleteMailingList`). +- ~~`events` feed (todo.sr.ht)~~ — a ticket activity feed under More. + +Four of the six planned. The other two did not survive contact: + +- `archiveMessage` is `@internal` and inaccessible. +- Webhook management, `shareSecret`, and build groups are reachable but declined + on judgement — see [SCOPE.md](SCOPE.md) for the reasoning, so they do not get + re-proposed. + +### Localization + +The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS = YES` but ships no +string catalog, so every user-facing string is hardcoded English. Roughly 634 +literals: 239 `Text(`, 150 `Label(`, 117 `Button(`, 77 `Section(`, 51 +`navigationTitle(`. + +Worth knowing before starting: a catalog containing only English changes nothing +for users until translations exist. It is groundwork, and it is the largest diff +in the roadmap — it touches nearly every view, with the regression risk that +implies. + +### Accessibility + +Labels and hints appear in 17 of 89 view files. Mechanical and low-risk, but it +cannot be verified from a build — it needs VoiceOver driven on a device. ### Swift 6 language mode diff --git a/SCOPE.md b/SCOPE.md index b4f668f..008d084 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -7,3 +7,24 @@ - Explore / search (hub.sr.ht) (no public discovery API) - Pronouns on profile (not in GraphQL schema) - Revoke personal access tokens (`@internal` in schema, inaccessible) +- Archive a message to a list (`archiveMessage` is `@internal`, inaccessible) +- Subscribe to a mailing list (`mailingListSubscribe` exists, but `MailingList` + has no `subscription` field and sr.ht has no discovery API, so there is no way + to find a list you are not already subscribed to — see hub.sr.ht above) +- Submitting patches (a `git send-email` flow, not a GraphQL mutation; Hutch + reviews patchsets but cannot send them) + +## Declined rather than blocked + +These are reachable in the API. They are left out on judgement, not capability. + +- **Webhook management** (24 fields across five services). A webhook needs an + HTTPS endpoint you control to receive POSTs. Without the relay above, this + only serves someone already running their own endpoint, and that person is not + managing it from a phone. Reconsider if `hutch-notify` ever ships. +- **`shareSecret`.** Shares a build secret — an SSH key or PAT — with another + user. A mistap grants someone else a credential, and nothing in the app can + take it back. That belongs on the web behind a full-size confirmation. The + read-only `secrets` list would be fine on its own. +- **Build groups** (`createGroup`, `startGroup`). Multi-job pipelines are + authored in `.build.yml`, not composed on a phone. -- cgit v1.2.3 From 2514b58a96ccb73b4feea8f74a2367c72ba824c8 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 00:18:53 -0500 Subject: fix: stop destructive swipes animating rows out before confirmation Swiping to delete made the row vanish and then spring back while the confirmation was still on screen. A destructive swipe action left to full-swipe performs itself on the gesture and animates the row away, but these actions only set pending state and wait for an answer, so the row returned when the data had not changed. allowsFullSwipe: false, which PasteListView already uses for exactly this confirm-then-delete shape. Both new swipes had the same omission. --- Hutch/Views/Lists/MailingListListView.swift | 6 +++++- Hutch/Views/Repositories/ArtifactsView.swift | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift index cc811df..77df89b 100644 --- a/Hutch/Views/Lists/MailingListListView.swift +++ b/Hutch/Views/Lists/MailingListListView.swift @@ -367,7 +367,11 @@ struct MailingListListView: View { } .padding(.vertical, 2) } - .swipeActions(edge: .trailing) { + // allowsFullSwipe: false, as in PasteListView. A destructive + // action left to full-swipe animates the row out on the gesture, + // before the confirmation is answered, so it flickers back when + // the data has not actually changed. + .swipeActions(edge: .trailing, allowsFullSwipe: false) { if isOwned(mailingList) { Button(role: .destructive) { pendingDeletion = mailingList diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index 264a51e..037c092 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -22,7 +22,9 @@ struct ArtifactsView: View { ArtifactRow(artifact: artifact) { openURL(artifact.url) } - .swipeActions(edge: .trailing) { + // See MailingListListView: a full-swipe destructive + // action animates the row out before the confirmation. + .swipeActions(edge: .trailing, allowsFullSwipe: false) { if isOwnedByCurrentUser { Button(role: .destructive) { pendingDeletion = artifact -- cgit v1.2.3 From 6898bc3fc00decee7224895ea75908daf0f97f59 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 00:29:34 -0500 Subject: fix: blank mailing list from Projects, swipe flicker, hidden upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three problems from manual testing. Opening a mailing list from More → Projects showed a blank screen, while the same tap on a project pinned to Home worked. handleTabNavigation reset the target path and appended to it two Task.yields later. When the target tab is already on screen — Projects lives under More — the reset starts an animated pop of the view the user is standing on and the appends land mid-animation. From Home the tab actually changes, so the More stack is quiescent and the appends land cleanly. Each case now builds its path and assigns it once, so SwiftUI gets a single diff with nothing to race. Destructive swipe actions made the row vanish and spring back while the confirmation was still up. role: .destructive makes SwiftUI perform the row removal on activation, which allowsFullSwipe: false does not prevent — the report was a tap, not a full swipe. These buttons only record pending state and wait for an answer, so they are plain buttons tinted red instead. Six sites: the two added here, plus trackers, pastes, and tracker ACLs and labels, which had the same flicker already. The artifacts upload control was invisible. It was declared as a toolbar item from a view that is a segment inside RepositoryDetailView's tab switch rather than its own navigation destination, so it never reached the navigation bar. It is a row in the list now, and also an action on the empty state — the overlay covers the list, and a repository with no artifacts is precisely the one that needs uploading. --- Hutch/App/RootView.swift | 48 +++++++++++++---------- Hutch/Views/Lists/MailingListListView.swift | 3 +- Hutch/Views/Pastes/PasteListView.swift | 3 +- Hutch/Views/Repositories/ArtifactsView.swift | 52 +++++++++++++++---------- Hutch/Views/Tickets/TrackerListView.swift | 3 +- Hutch/Views/Tickets/TrackerManagementView.swift | 6 ++- 6 files changed, 69 insertions(+), 46 deletions(-) diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 32d1624..0194d63 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -291,39 +291,45 @@ struct RootView: View { } } + /// Replaces the target tab's path in one assignment. + /// + /// Resetting the path and appending to it afterwards races when the target tab + /// is already the one on screen: the reset starts an animated pop of the view + /// the user is standing on, and the appends land mid-animation, leaving a blank + /// screen. That is why opening a mailing list from a pinned project on Home + /// worked while the same tap under More → Projects did not — one changes tabs + /// and the other does not. + /// + /// Building the whole path first and assigning once gives SwiftUI a single + /// diff, with nothing to race. private func handleTabNavigation(_ target: AppState.TabNavigationTarget) { switch target { case .repository(let repository): - repoPath = NavigationPath() + var path = NavigationPath() + path.append(repository) + repoPath = path appState.selectedTab = .repositories - Task { - await settleNavigationTransition() - repoPath.append(repository) - } case .tracker(let tracker): - ticketsPath = NavigationPath() + var path = NavigationPath() + path.append(tracker) + ticketsPath = path appState.selectedTab = .tickets - Task { - await settleNavigationTransition() - ticketsPath.append(tracker) - } case .mailingList(let mailingList): - morePath = NavigationPath() + // .lists first so back lands on Mailing Lists rather than dead-ending. + var path = NavigationPath() + path.append(MoreRoute.lists) + path.append(MoreRoute.mailingList(mailingList)) + morePath = path appState.selectedTab = .more - Task { - await settleNavigationTransition() - morePath.append(MoreRoute.lists) - morePath.append(MoreRoute.mailingList(mailingList)) - } + case .systemStatus: - morePath = NavigationPath() + var path = NavigationPath() + path.append(MoreRoute.systemStatus) + morePath = path appState.selectedTab = .more - Task { - await settleNavigationTransition() - morePath.append(MoreRoute.systemStatus) - } + case .builds: buildsPath = NavigationPath() appState.selectedTab = .builds diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift index 77df89b..1b159bf 100644 --- a/Hutch/Views/Lists/MailingListListView.swift +++ b/Hutch/Views/Lists/MailingListListView.swift @@ -373,11 +373,12 @@ struct MailingListListView: View { // the data has not actually changed. .swipeActions(edge: .trailing, allowsFullSwipe: false) { if isOwned(mailingList) { - Button(role: .destructive) { + Button { pendingDeletion = mailingList } label: { SwiftUI.Label("Delete", systemImage: "trash") } + .tint(.red) Button { editingList = mailingList } label: { diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift index b325153..b2c7838 100644 --- a/Hutch/Views/Pastes/PasteListView.swift +++ b/Hutch/Views/Pastes/PasteListView.swift @@ -90,11 +90,12 @@ struct PasteListView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if swipeActionsEnabled { - Button(role: .destructive) { + Button { pasteToDelete = paste } label: { Label("Delete", systemImage: "trash") } + .tint(.red) } } .task { diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index 037c092..752c7c5 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -16,6 +16,21 @@ struct ArtifactsView: View { var body: some View { List { + // In the list rather than the toolbar: this view is a segment inside + // RepositoryDetailView's tab switch, not its own navigation + // destination, and a toolbar declared from there does not reliably + // reach the navigation bar. It also has to be reachable when there are + // no artifacts at all, which is the state a new tag is in. + if isOwnedByCurrentUser { + Button { + showTagPicker = true + } label: { + SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") + } + .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) + .themedRow() + } + ForEach(viewModel.referenceArtifacts) { refArtifacts in Section { ForEach(refArtifacts.artifacts) { artifact in @@ -26,11 +41,12 @@ struct ArtifactsView: View { // action animates the row out before the confirmation. .swipeActions(edge: .trailing, allowsFullSwipe: false) { if isOwnedByCurrentUser { - Button(role: .destructive) { + Button { pendingDeletion = artifact } label: { SwiftUI.Label("Delete", systemImage: "trash") } + .tint(.red) } } } @@ -83,20 +99,6 @@ struct ArtifactsView: View { } message: { _ in Text("This permanently removes the artifact from the tag. This cannot be undone.") } - // The sections above only list tags that already have an artifact, so - // without this there would be no way to attach the first one to a tag. - .toolbar { - if isOwnedByCurrentUser { - ToolbarItem(placement: .topBarTrailing) { - Button { - showTagPicker = true - } label: { - SwiftUI.Label("Upload Artifact", systemImage: "square.and.arrow.up") - } - .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) - } - } - } .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) { ForEach(viewModel.tags.prefix(12), id: \.name) { tag in Button(RepositorySummary.displayBranchName(for: tag.name)) { @@ -125,11 +127,21 @@ struct ArtifactsView: View { retryAction: { await viewModel.loadArtifacts() } ) } else if viewModel.referenceArtifacts.isEmpty { - ContentUnavailableView( - "No Artifacts", - systemImage: "archivebox", - description: Text("This repository has no release artifacts.") - ) + // The overlay covers the whole list, so the upload row above is + // hidden underneath it — and a repository with no artifacts is + // exactly the one that needs uploading. Offer it here too. + ContentUnavailableView { + SwiftUI.Label("No Artifacts", systemImage: "archivebox") + } description: { + Text("This repository has no release artifacts.") + } actions: { + if isOwnedByCurrentUser { + Button("Upload Artifact…") { + showTagPicker = true + } + .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) + } + } } } .task { diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift index 5deb513..cb4a59c 100644 --- a/Hutch/Views/Tickets/TrackerListView.swift +++ b/Hutch/Views/Tickets/TrackerListView.swift @@ -145,11 +145,12 @@ struct TrackerListView: View { TrackerRowView(tracker: tracker) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { + Button { pendingDeletion = tracker } label: { Label("Delete", systemImage: "trash") } + .tint(.red) Button { editingTracker = tracker diff --git a/Hutch/Views/Tickets/TrackerManagementView.swift b/Hutch/Views/Tickets/TrackerManagementView.swift index fad1ae8..73b2a08 100644 --- a/Hutch/Views/Tickets/TrackerManagementView.swift +++ b/Hutch/Views/Tickets/TrackerManagementView.swift @@ -751,11 +751,12 @@ struct TrackerACLManagementSheet: View { TrackerPermissionSummary(permissions: entry.permissions) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { + Button { pendingDeletion = entry } label: { Label("Delete", systemImage: "trash") } + .tint(.red) Button { editingACL = entry @@ -1132,11 +1133,12 @@ struct TrackerLabelManagementSheet: View { } .tint(.blue) - Button(role: .destructive) { + Button { pendingDeletion = label } label: { Label("Delete", systemImage: "trash") } + .tint(.red) } } .themedRow() -- cgit v1.2.3 From 77cd5b5e56ec054b451a6165162fef655873a5d6 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 00:42:38 -0500 Subject: fix: push mailing lists locally, fix upload menu, drop the events feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a mailing list from More → Projects still blanked. The cause was not in handleTabNavigation: the row called openMailingList and then dismiss(), so a path rebuild and a pop of this very view raced each other. Projects already lives in the More tab, so there is nothing to navigate to — push MailingListDetailView directly, which also lands back on the project rather than on Mailing Lists. Sources and trackers keep routing, because they really do land in other tabs. The upload controls did nothing. Two .confirmationDialog modifiers on one view leave one silently dead, and this view already had one for delete, so the tag picker never presented. It is a Menu now, which also puts the tags one tap away instead of two. The ticket activity feed is removed. todo.sr.ht's root events resolver joins event.participant_id, which references participant(id), against participant.user_id — different id spaces — so it returns an empty list for every user. The rows exist; that join cannot find them. Ticket.events is unaffected because it filters on ticket_id, which is why ticket timelines work. No client can fix this, and a screen that is permanently empty while blaming the token's scopes is worse than no screen. Recorded in SCOPE.md with the query. --- Hutch/App/RootView.swift | 3 - Hutch/Views/Activity/ActivityView.swift | 96 ------------ Hutch/Views/Activity/ActivityViewModel.swift | 210 --------------------------- Hutch/Views/Lookup/LookupView.swift | 2 - Hutch/Views/More/MoreView.swift | 7 - Hutch/Views/Projects/ProjectDetailView.swift | 11 +- Hutch/Views/Repositories/ArtifactsView.swift | 46 +++--- README.md | 1 - ROADMAP.md | 7 +- SCOPE.md | 15 ++ 10 files changed, 52 insertions(+), 346 deletions(-) delete mode 100644 Hutch/Views/Activity/ActivityView.swift delete mode 100644 Hutch/Views/Activity/ActivityViewModel.swift diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 0194d63..10720ad 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -445,7 +445,6 @@ enum MoreRoute: Hashable { case projectDashboard(id: String, title: String?) case mailingList(InboxMailingListReference) case thread(InboxThreadSummary) - case activity case manPageBrowser case manPage(URL) } @@ -479,8 +478,6 @@ private struct MoreNavigationRoot: View { ProjectDashboardDeepLinkView(projectID: id, title: title) case .mailingList(let mailingList): MailingListDetailView(mailingList: mailingList) - case .activity: - ActivityView() case .thread(let thread): ThreadDetailView( thread: thread, diff --git a/Hutch/Views/Activity/ActivityView.swift b/Hutch/Views/Activity/ActivityView.swift deleted file mode 100644 index 6379d45..0000000 --- a/Hutch/Views/Activity/ActivityView.swift +++ /dev/null @@ -1,96 +0,0 @@ -import SwiftUI - -struct ActivityView: View { - @Environment(AppState.self) private var appState - @State private var viewModel: ActivityViewModel? - - var body: some View { - Group { - if let viewModel { - content(viewModel) - } else { - SRHTLoadingStateView(message: "Loading Activity…") - } - } - .navigationTitle("Activity") - .navigationBarTitleDisplayMode(.inline) - .task { - let model = viewModel ?? ActivityViewModel(client: appState.client) - viewModel = model - await model.loadIfNeeded() - } - } - - @ViewBuilder - private func content(_ viewModel: ActivityViewModel) -> some View { - List { - ForEach(viewModel.events) { event in - NavigationLink { - TicketDetailView( - ownerUsername: event.ownerUsername, - trackerName: event.trackerName, - trackerId: event.trackerID, - trackerRid: event.trackerRID, - ticketId: event.ticketID - ) - } label: { - ActivityRow(event: event) - } - .themedRow() - } - - if viewModel.hasMore { - HStack { - Spacer() - ProgressView() - Spacer() - } - .themedRow() - .task { await viewModel.loadMore() } - } - } - .themedList() - .listStyle(.plain) - .refreshable { await viewModel.load() } - .overlay { - if viewModel.isLoading, viewModel.events.isEmpty { - SRHTLoadingStateView(message: "Loading Activity…") - } else if let error = viewModel.error, viewModel.events.isEmpty { - SRHTErrorStateView( - title: "Couldn't Load Activity", - message: error, - retryAction: { await viewModel.load() } - ) - } else if viewModel.events.isEmpty { - ContentUnavailableView( - "No Activity", - systemImage: "bell", - description: Text("Ticket activity you are subscribed to or involved in appears here.") - ) - } - } - } -} - -private struct ActivityRow: View { - let event: ActivityEvent - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(event.ticketSubject) - .font(.subheadline.weight(.medium)) - .lineLimit(2) - - Text("\(event.summary) • \(event.created.relativeDescription)") - .font(.caption) - .foregroundStyle(.secondary) - - Text("\(event.trackerOwner.canonicalName)/\(event.trackerName) #\(event.ticketID)") - .font(.caption2) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 2) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(event.ticketSubject), \(event.summary), \(event.created.relativeDescription)") - } -} diff --git a/Hutch/Views/Activity/ActivityViewModel.swift b/Hutch/Views/Activity/ActivityViewModel.swift deleted file mode 100644 index 77e500d..0000000 --- a/Hutch/Views/Activity/ActivityViewModel.swift +++ /dev/null @@ -1,210 +0,0 @@ -import Foundation - -// MARK: - Response types (file-private to avoid @MainActor Decodable issues) - -private struct ActivityResponse: Decodable, Sendable { - /// Nullable in the schema, and null when the token lacks the EVENTS scope. - let events: ActivityPage? -} - -private struct ActivityPage: Decodable, Sendable { - let results: [ActivityEventPayload] - let cursor: String? -} - -private struct ActivityEventPayload: Decodable, Sendable { - let id: Int - let created: Date - let changes: [EventChange] - let ticket: ActivityTicketPayload -} - -private struct ActivityTicketPayload: Decodable, Sendable { - let id: Int - let subject: String - let tracker: ActivityTrackerPayload -} - -private struct ActivityTrackerPayload: Decodable, Sendable { - let id: Int - let rid: String - let name: String - let owner: Entity -} - -// MARK: - View Model - -/// The authenticated user's ticket activity across every tracker. -/// -/// todo.sr.ht's root `events` returns what the user is subscribed to or -/// implicated in, newest first — the closest thing sr.ht offers to a personal -/// feed, and it works across trackers the user does not own. -@Observable -@MainActor -final class ActivityViewModel { - - private(set) var events: [ActivityEvent] = [] - private(set) var isLoading = false - private(set) var isLoadingMore = false - private(set) var hasMore = false - var error: String? - - private var cursor: String? - private let client: SRHTClient - - init(client: SRHTClient) { - self.client = client - } - - private static let eventsQuery = """ - query activity($cursor: Cursor) { - events(cursor: $cursor) { - results { - id - created - changes { - eventType: __typename - ... on Created { __typename } - ... on Comment { - author { canonicalName } - text - authenticity - } - ... on StatusChange { - oldStatus - newStatus - } - ... on LabelUpdate { - labeler { canonicalName } - label { name } - } - ... on Assignment { - assigner { canonicalName } - assignee { canonicalName } - } - } - ticket { - id - subject - tracker { id rid name owner { canonicalName } } - } - } - cursor - } - } - """ - - func loadIfNeeded() async { - guard events.isEmpty, !isLoading else { return } - await load() - } - - func load() async { - guard !isLoading else { return } - isLoading = true - error = nil - defer { isLoading = false } - - cursor = nil - do { - let page = try await fetch(cursor: nil) - events = page.events - cursor = page.cursor - hasMore = page.cursor != nil - } catch { - self.error = error.userFacingMessage - } - } - - func loadMore() async { - guard !isLoadingMore, !isLoading, let cursor else { return } - isLoadingMore = true - defer { isLoadingMore = false } - - do { - let page = try await fetch(cursor: cursor) - events.append(contentsOf: page.events) - self.cursor = page.cursor - hasMore = page.cursor != nil - } catch { - // Keep what is already on screen; the next scroll can retry. - self.error = error.userFacingMessage - } - } - - private func fetch(cursor: String?) async throws -> (events: [ActivityEvent], cursor: String?) { - var variables: [String: any Sendable] = [:] - if let cursor { - variables["cursor"] = cursor - } - - let response = try await client.execute( - service: .todo, - query: Self.eventsQuery, - variables: variables.isEmpty ? nil : variables, - responseType: ActivityResponse.self - ) - - guard let page = response.events else { - throw SRHTError.graphQLErrors([ - GraphQLError(message: "Your token does not grant access to ticket events.", locations: nil) - ]) - } - - let mapped = page.results.map { payload in - ActivityEvent( - id: payload.id, - created: payload.created, - changes: payload.changes, - ticketID: payload.ticket.id, - ticketSubject: payload.ticket.subject, - trackerID: payload.ticket.tracker.id, - trackerRID: payload.ticket.tracker.rid, - trackerName: payload.ticket.tracker.name, - trackerOwner: payload.ticket.tracker.owner - ) - } - return (mapped, page.cursor) - } -} - -/// One entry in the activity feed, flattened so the row does not have to walk -/// into the ticket and tracker payloads. -struct ActivityEvent: Identifiable, Sendable { - let id: Int - let created: Date - let changes: [EventChange] - let ticketID: Int - let ticketSubject: String - let trackerID: Int - let trackerRID: String - let trackerName: String - let trackerOwner: Entity - - var ownerUsername: String { - trackerOwner.canonicalName.hasPrefix("~") - ? String(trackerOwner.canonicalName.dropFirst()) - : trackerOwner.canonicalName - } - - /// A one-line description of what happened, from the first change. - var summary: String { - guard let change = changes.first else { return "Updated" } - switch change.eventType { - case "Created": return "Filed" - case "Comment": return "Commented" - case "StatusChange": - if let newStatus = change.newStatus { - return "Status \(newStatus.displayName.lowercased())" - } - return "Status changed" - case "LabelUpdate": return change.label.map { "Labeled \($0.name)" } ?? "Labels changed" - case "Assignment": - if let assignee = change.assignee { - return "Assigned \(assignee.canonicalName)" - } - return "Assignment changed" - default: return "Updated" - } - } -} diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift index 3b35325..2a26282 100644 --- a/Hutch/Views/Lookup/LookupView.swift +++ b/Hutch/Views/Lookup/LookupView.swift @@ -465,8 +465,6 @@ struct LookupView: View { ProjectDashboardDeepLinkView(projectID: id, title: title) case .mailingList(let mailingList): MailingListDetailView(mailingList: mailingList) - case .activity: - ActivityView() case .thread(let thread): ThreadDetailView( thread: thread, diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index eda918f..ad02077 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -19,13 +19,6 @@ struct MoreView: View { .themedRow() } - Section("Activity") { - NavigationLink(value: MoreRoute.activity) { - Label("Ticket Activity", systemImage: "bell.badge") - } - .themedRow() - } - Section("Other Services") { NavigationLink(value: MoreRoute.projects) { Label("Projects", systemImage: "square.stack.3d.up") diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift index b5c6cdb..c30ec88 100644 --- a/Hutch/Views/Projects/ProjectDetailView.swift +++ b/Hutch/Views/Projects/ProjectDetailView.swift @@ -196,9 +196,14 @@ struct ProjectDetailView: View { if !displayedProject.mailingLists.isEmpty { Section("Mailing Lists") { ForEach(displayedProject.mailingLists) { mailingList in - Button { - appState.openMailingList(mailingList.inboxReference) - dismiss() + // Pushed here rather than routed through AppState. Projects + // already lives in the More tab, so asking for a tab + // navigation made the path rebuild itself while dismiss() + // popped this view out from under it, leaving a blank screen. + // Sources and trackers still route, because they genuinely + // land in other tabs. + NavigationLink { + MailingListDetailView(mailingList: mailingList.inboxReference) } label: { ProjectResourceRow( title: mailingList.displayName, diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index 752c7c5..30ab0b1 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -10,10 +10,30 @@ struct ArtifactsView: View { @State private var uploadTargetRef: String? @State private var pendingDeletion: ArtifactInfo? - @State private var showTagPicker = false private var isOwnedByCurrentUser: Bool { canManage } + /// A menu rather than a confirmation dialog: this view already presents one + /// for delete, and two .confirmationDialog modifiers on the same view leave + /// one of them silently dead. A menu also puts the tags one tap away. + @ViewBuilder + private var uploadMenu: some View { + Menu { + if viewModel.tags.isEmpty { + Text("This repository has no tags") + } else { + ForEach(viewModel.tags.prefix(12), id: \.name) { tag in + Button(RepositorySummary.displayBranchName(for: tag.name)) { + uploadTargetRef = tag.name + } + } + } + } label: { + SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") + } + .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) + } + var body: some View { List { // In the list rather than the toolbar: this view is a segment inside @@ -22,13 +42,8 @@ struct ArtifactsView: View { // reach the navigation bar. It also has to be reachable when there are // no artifacts at all, which is the state a new tag is in. if isOwnedByCurrentUser { - Button { - showTagPicker = true - } label: { - SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") - } - .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) - .themedRow() + uploadMenu + .themedRow() } ForEach(viewModel.referenceArtifacts) { refArtifacts in @@ -99,16 +114,6 @@ struct ArtifactsView: View { } message: { _ in Text("This permanently removes the artifact from the tag. This cannot be undone.") } - .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) { - ForEach(viewModel.tags.prefix(12), id: \.name) { tag in - Button(RepositorySummary.displayBranchName(for: tag.name)) { - uploadTargetRef = tag.name - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("Artifacts attach to a tag. Filenames must be unique within the repository.") - } .themedList() .listStyle(.insetGrouped) .task { @@ -136,10 +141,7 @@ struct ArtifactsView: View { Text("This repository has no release artifacts.") } actions: { if isOwnedByCurrentUser { - Button("Upload Artifact…") { - showTagPicker = true - } - .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) + uploadMenu } } } diff --git a/README.md b/README.md index 721d456..34c7d03 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ The app currently includes: - Inbox and mailing list reading flows - Patchset review: cover letters, per-patch diffs, checks, version chains, and status changes - Mailing list creation, settings, and deletion -- Ticket activity feed across every tracker you follow - Paste browsing, creation, and detail views - Profile and account settings, including SSH keys, PGP keys, personal access token management, and the audit log - Email preferences for todo.sr.ht and lists.sr.ht diff --git a/ROADMAP.md b/ROADMAP.md index 7c64af3..2436877 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,11 +140,14 @@ are sized very differently — measure before committing to one. - ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile. - ~~Mailing list creation and settings~~ (`createMailingList`, `updateMailingList`, `deleteMailingList`). -- ~~`events` feed (todo.sr.ht)~~ — a ticket activity feed under More. -Four of the six planned. The other two did not survive contact: +Three of the six planned. The other three did not survive contact: - `archiveMessage` is `@internal` and inaccessible. +- The `events` feed was built, then removed: todo.sr.ht's root `events` resolver + joins `event.participant_id` against `participant.user_id`, which are + different id spaces, so it returns an empty list for everyone. See + [SCOPE.md](SCOPE.md). - Webhook management, `shareSecret`, and build groups are reachable but declined on judgement — see [SCOPE.md](SCOPE.md) for the reasoning, so they do not get re-proposed. diff --git a/SCOPE.md b/SCOPE.md index 008d084..407f292 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -8,6 +8,21 @@ - Pronouns on profile (not in GraphQL schema) - Revoke personal access tokens (`@internal` in schema, inaccessible) - Archive a message to a list (`archiveMessage` is `@internal`, inaccessible) +- Ticket activity feed (todo.sr.ht's root `events` query is broken upstream and + returns an empty list for every user). `event.participant_id` references + `participant(id)`, but the resolver joins it against `participant.user_id`: + + ```sql + FROM event ev + JOIN participant p ON p.user_id = ev.participant_id -- id space vs user id space + WHERE p.user_id = + ``` + + The rows exist — the writer inserts `participant.ID` for the submitter and for + every subscriber — but that join cannot find them. `Ticket.events` is + unaffected because it filters on `ev.ticket_id`, which is why ticket timelines + work. Nothing a client can do fixes this; revisit only if sr.ht changes the + resolver. - Subscribe to a mailing list (`mailingListSubscribe` exists, but `MailingList` has no `subscription` field and sr.ht has no discovery API, so there is no way to find a list you are not already subscribed to — see hub.sr.ht above) -- cgit v1.2.3 From d32ad837ac9eb153598c10c3cf47fbdb4e51e3ba Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 00:54:59 -0500 Subject: fix: make artifact upload actually fire, and show it when it fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a file did nothing. The fileImporter's isPresented binding was derived from uploadTargetRef and nilled it on dismissal, but dismissal happens before the completion runs — so the completion read nil and returned without uploading. Presentation state and payload state cannot be the same state. A plain isImporting bool drives presentation now; the tag survives in uploadTargetRef until the completion consumes it. The upload menu was also disabled when the repository has no tags, while the explanation for that state lived inside the menu — unreachable exactly when it applies, so the tap died with no reason given. sr.ht requires revspec to match a tag, so having none is a real state worth explaining rather than hiding. Failures were invisible too. uploadArtifact and deleteArtifact set error, but the overlay only renders it when the list is empty, so a rejection on a repository that already has artifacts — a duplicate filename is the likely one, since sr.ht requires filenames to be unique per repository — set an error nobody saw. The tab carries an error banner now. --- Hutch/Views/Repositories/ArtifactsView.swift | 47 ++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index 30ab0b1..8843c6f 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -1,5 +1,11 @@ import SwiftUI import UniformTypeIdentifiers +import os + +#if DEBUG +/// Temporary: diagnosing why the upload menu swallows taps. +private let artifactsLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Artifacts") +#endif struct ArtifactsView: View { let viewModel: RepositoryDetailViewModel @@ -9,6 +15,7 @@ struct ArtifactsView: View { @Environment(\.openURL) private var openURL @State private var uploadTargetRef: String? + @State private var isImporting = false @State private var pendingDeletion: ArtifactInfo? private var isOwnedByCurrentUser: Bool { canManage } @@ -25,17 +32,23 @@ struct ArtifactsView: View { ForEach(viewModel.tags.prefix(12), id: \.name) { tag in Button(RepositorySummary.displayBranchName(for: tag.name)) { uploadTargetRef = tag.name + isImporting = true } } } } label: { SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") } - .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) + // Deliberately not disabled when there are no tags. The explanation for + // that state lives inside the menu, and disabling the control makes the + // explanation unreachable — the tap just dies with no reason given. + .disabled(viewModel.isMutatingArtifact) } var body: some View { - List { + @Bindable var vm = viewModel + + return List { // In the list rather than the toolbar: this view is a segment inside // RepositoryDetailView's tab switch, not its own navigation // destination, and a toolbar declared from there does not reliably @@ -75,6 +88,7 @@ struct ArtifactsView: View { // on the tag rather than in the toolbar. Button { uploadTargetRef = refArtifacts.name + isImporting = true } label: { SwiftUI.Label("Upload", systemImage: "plus.circle") .font(.caption) @@ -85,18 +99,18 @@ struct ArtifactsView: View { } } } + // isImporting drives presentation; uploadTargetRef carries the tag. They + // have to be separate: a binding derived from uploadTargetRef clears it on + // dismissal, and dismissal happens before the completion runs — so the + // completion read nil and returned without uploading anything. .fileImporter( - isPresented: .init( - get: { uploadTargetRef != nil }, - set: { if !$0 { uploadTargetRef = nil } } - ), + isPresented: $isImporting, allowedContentTypes: [.data] ) { result in - guard let revspec = uploadTargetRef else { return } + let revspec = uploadTargetRef uploadTargetRef = nil - if case .success(let fileURL) = result { - Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) } - } + guard let revspec, case .success(let fileURL) = result else { return } + Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) } } .confirmationDialog( pendingDeletion.map { "Delete \($0.filename)?" } ?? "", @@ -116,11 +130,24 @@ struct ArtifactsView: View { } .themedList() .listStyle(.insetGrouped) + .srhtErrorBanner(error: $vm.error) .task { // Tags drive the picker above and are not otherwise needed by this tab. if isOwnedByCurrentUser, viewModel.tags.isEmpty { await viewModel.loadReferences() } + #if DEBUG + // Temporary: diagnosing why the upload menu swallows taps. + artifactsLogger.debug( + """ + canManage=\(canManage, privacy: .public) \ + tags=\(viewModel.tags.count, privacy: .public) \ + isMutating=\(viewModel.isMutatingArtifact, privacy: .public) \ + menuDisabled=\(viewModel.isMutatingArtifact || viewModel.tags.isEmpty, privacy: .public) \ + error=\(viewModel.error ?? "nil", privacy: .public) + """ + ) + #endif } .overlay { if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty { -- cgit v1.2.3 From 871b04159aa47c0b0e62e2520c2f30e81f0f024b Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 01:06:46 -0500 Subject: fix: download artifacts through the API instead of handing them to Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping download opened Artifact.url in the browser, which answered with "Authorization header is required". That URL is not a web page: git.sr.ht resolves it to /query/artifact//, which demands a bearer token. Safari has none and no way to get one, so the download could never have worked — this predates the upload work. Fetch it with the client that already holds the token and hand the user the file through a share sheet. fetchData mirrors fetchText, including its host guard, so an authenticated request still cannot be aimed anywhere but *.sr.ht over https. Also guards zero-byte uploads. sr.ht streams into S3, which rejects a zero-part multipart completion with "MalformedXML: UnknownError" — an error that says nothing about the cause and cost a round of testing to identify. Empty files are now refused by name before the request is made. --- Hutch/Networking/SRHTClient.swift | 37 ++++++++++++++++++++++ Hutch/Views/Repositories/ArtifactsView.swift | 21 ++++++++++-- .../Repositories/RepositoryDetailViewModel.swift | 31 ++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index 94531f4..8aaa4aa 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -276,6 +276,43 @@ final class SRHTClient: Sendable { // MARK: - Plain-text fetch + /// Fetch the bytes at a URL using the same authorization header. + /// + /// sr.ht serves some resources from the API origin rather than the web one — + /// `Artifact.url` is `https://git.sr.ht/query/artifact//` + /// — and those return an auth error to anything without a bearer token. They + /// cannot be handed to a browser; they have to be fetched here. + func fetchData(url: URL) async throws -> Data { + guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { + throw SRHTError.unauthorized + } + guard Self.isTrustedAuthenticatedTextURL(url) else { + throw SRHTError.invalidAuthenticatedURL(url) + } + + var request = URLRequest(url: url) + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw SRHTError.networkError(error) + } + + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { + throw SRHTError.unauthorized + } + if !(200...299).contains(http.statusCode) { + throw SRHTError.httpError(http.statusCode) + } + } + + return data + } + /// Fetch the contents of a URL as plain text, using the same authorization header. /// Used for build logs and other non-GraphQL resources. func fetchText(url: URL) async throws -> String { diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index 8843c6f..1d9fc6c 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -12,11 +12,11 @@ struct ArtifactsView: View { /// Passed in rather than recomputed: RepositoryDetailView already owns this /// check and gates its other management surfaces on it. var canManage: Bool = false - @Environment(\.openURL) private var openURL @State private var uploadTargetRef: String? @State private var isImporting = false @State private var pendingDeletion: ArtifactInfo? + @State private var downloadedFile: DownloadedArtifact? private var isOwnedByCurrentUser: Bool { canManage } @@ -63,7 +63,14 @@ struct ArtifactsView: View { Section { ForEach(refArtifacts.artifacts) { artifact in ArtifactRow(artifact: artifact) { - openURL(artifact.url) + Task { + // Artifact.url is on the API origin and 401s + // without a bearer token, so it cannot be handed + // to a browser. Fetch it and share the file. + if let fileURL = await viewModel.downloadArtifact(artifact) { + downloadedFile = DownloadedArtifact(url: fileURL) + } + } } // See MailingListListView: a full-swipe destructive // action animates the row out before the confirmation. @@ -131,6 +138,9 @@ struct ArtifactsView: View { .themedList() .listStyle(.insetGrouped) .srhtErrorBanner(error: $vm.error) + .sheet(item: $downloadedFile) { download in + FileContentShareSheet(activityItems: [download.url]) + } .task { // Tags drive the picker above and are not otherwise needed by this tab. if isOwnedByCurrentUser, viewModel.tags.isEmpty { @@ -184,6 +194,13 @@ struct ArtifactsView: View { } } +/// Wraps the downloaded file for `.sheet(item:)`. URL is not Identifiable, and +/// conforming a stdlib type retroactively is worse than a four-line struct. +private struct DownloadedArtifact: Identifiable { + let id = UUID() + let url: URL +} + private struct ArtifactRow: View { let artifact: ArtifactInfo let onDownload: () -> Void diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift index dce39c1..9b6b942 100644 --- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -542,6 +542,14 @@ final class RepositoryDetailViewModel { return false } + // sr.ht streams the upload into S3, which rejects a zero-part multipart + // completion with "MalformedXML" — an error that says nothing about the + // actual problem. Catch it here where we can name it. + guard !fileData.isEmpty else { + self.error = "\(fileURL.lastPathComponent) is empty. SourceHut rejects zero-byte artifacts." + return false + } + do { _ = try await client.executeMultipart( service: service, @@ -567,6 +575,29 @@ final class RepositoryDetailViewModel { } } + /// Downloads an artifact and returns a local file URL to share. + /// + /// `Artifact.url` points at the API origin, not the web one, and returns an + /// auth error to anything without a bearer token — so it cannot be opened in + /// a browser. Fetch it here and hand the user the file instead. + func downloadArtifact(_ artifact: ArtifactInfo) async -> URL? { + guard !isMutatingArtifact else { return nil } + isMutatingArtifact = true + error = nil + defer { isMutatingArtifact = false } + + do { + let data = try await client.fetchData(url: artifact.url) + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent(artifact.filename) + try data.write(to: destination, options: .atomic) + return destination + } catch { + self.error = "Couldn't download \(artifact.filename). \(error.userFacingMessage)" + return nil + } + } + @discardableResult func deleteArtifact(id: Int) async -> Bool { guard !isMutatingArtifact else { return false } -- cgit v1.2.3 From ad108c76d062099af9e698e1f0ba0d8cecfeab1f Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 01:16:39 -0500 Subject: fix: deleted repositories linger until the cache expires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a repository left it on the list, and pulling to refresh did not shift it. Two independent reasons, both cache-related. deleteRepository never invalidated anything. Creation invalidates the repositories and home prefixes; deletion was written without it, so the list and Home kept serving a repository that no longer exists. And forceRefresh only ever reached the build statuses — its own doc comment says so — while the repository list itself was pinned to useCache: true. So a pull to refresh re-served the same cache it already had. fetchPage already takes useCache and falls through to an uncached fetch; it was simply never told. --- Hutch/Views/Repositories/RepositoryListViewModel.swift | 5 ++++- Hutch/Views/Repositories/RepositorySettingsViewModel.swift | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift index 695abf8..b3669e6 100644 --- a/Hutch/Views/Repositories/RepositoryListViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift @@ -189,7 +189,10 @@ final class RepositoryListViewModel { filteredResults = [] } } else { - let repositories = try await fetchAllRepositories(useCache: true) + // forceRefresh used to reach only the build statuses, so a pull to + // refresh re-served the cached list and a deleted repository stayed + // on screen. + let repositories = try await fetchAllRepositories(useCache: !forceRefresh) updateSearchIndex(with: repositories) filteredResults = repositories } diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift index dd8d7a1..0b31125 100644 --- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift +++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift @@ -231,6 +231,11 @@ final class RepositorySettingsViewModel { variables: ["id": repositoryId], responseType: DeleteRepositoryResponse.self ) + // The list and Home are both served from cache, so without this the + // repository lingers on screen after it no longer exists. Creation + // already does this; deletion never did. + await client.invalidateCache(prefix: APICacheKeys.prefix(service.rawValue, "repositories")) + await client.invalidateCache(prefix: APICacheKeys.prefix("home")) didDelete = true } catch { self.error = error.userFacingMessage -- cgit v1.2.3 From 21be03e6ca54c1a15607faab905b119eda9a548f Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 01:21:33 -0500 Subject: chore: remove the artifacts debug probe It did its job: tags=1 confirmed the menu was disabled by its own tags check while the explanation sat unreachable inside it. --- Hutch/Views/Repositories/ArtifactsView.swift | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index 1d9fc6c..69da0d6 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -1,11 +1,5 @@ import SwiftUI import UniformTypeIdentifiers -import os - -#if DEBUG -/// Temporary: diagnosing why the upload menu swallows taps. -private let artifactsLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Artifacts") -#endif struct ArtifactsView: View { let viewModel: RepositoryDetailViewModel @@ -146,18 +140,6 @@ struct ArtifactsView: View { if isOwnedByCurrentUser, viewModel.tags.isEmpty { await viewModel.loadReferences() } - #if DEBUG - // Temporary: diagnosing why the upload menu swallows taps. - artifactsLogger.debug( - """ - canManage=\(canManage, privacy: .public) \ - tags=\(viewModel.tags.count, privacy: .public) \ - isMutating=\(viewModel.isMutatingArtifact, privacy: .public) \ - menuDisabled=\(viewModel.isMutatingArtifact || viewModel.tags.isEmpty, privacy: .public) \ - error=\(viewModel.error ?? "nil", privacy: .public) - """ - ) - #endif } .overlay { if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty { -- cgit v1.2.3