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(-) (limited to 'Hutch/Views/Lists') 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 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(-) (limited to 'Hutch/Views/Lists') 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(-) (limited to 'Hutch/Views/Lists') 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