diff options
| -rw-r--r-- | Hutch/Views/Lists/MailingListListView.swift | 361 |
1 files 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<String>.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() + } + } + } + } +} |
