Commit 1d894ad72b
Unsigned
Layout: unified · split
Hutch/Views/Lists/MailingListListView.swift +356 −5
| @@ -1,5 +1,9 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | private struct ListIDPayload: Decodable, Sendable { | |
| 4 | let id: Int | |
| 5 | } | |
| 6 | ||
| 3 | 7 | @Observable |
| 4 | 8 | @MainActor |
| 5 | 9 | final class MailingListListViewModel { |
| @@ -35,10 +39,176 @@ final class MailingListListViewModel { | ||
| 35 | 39 | } |
| 36 | 40 | """ |
| 37 | 41 | |
| 42 | private static let createMailingListMutation = """ | |
| 43 | mutation createMailingList($name: String!, $description: String, $visibility: Visibility!) { | |
| 44 | createMailingList(name: $name, description: $description, visibility: $visibility) { | |
| 45 | id | |
| 46 | rid | |
| 47 | name | |
| 48 | owner { canonicalName } | |
| 49 | } | |
| 50 | } | |
| 51 | """ | |
| 52 | ||
| 53 | /// InboxMailingListReference carries only id/rid/name/owner, so the settings | |
| 54 | /// sheet has to read the current values before it can offer to change them — | |
| 55 | /// otherwise saving would blank the description and reset visibility. | |
| 56 | private static let listSettingsQuery = """ | |
| 57 | query listSettings($rid: ID!) { | |
| 58 | list(rid: $rid) { | |
| 59 | description | |
| 60 | visibility | |
| 61 | } | |
| 62 | } | |
| 63 | """ | |
| 64 | ||
| 65 | private static let updateMailingListMutation = """ | |
| 66 | mutation updateMailingList($id: Int!, $input: MailingListInput!) { | |
| 67 | updateMailingList(id: $id, input: $input) { id } | |
| 68 | } | |
| 69 | """ | |
| 70 | ||
| 71 | private static let deleteMailingListMutation = """ | |
| 72 | mutation deleteMailingList($id: Int!) { | |
| 73 | deleteMailingList(id: $id) { id } | |
| 74 | } | |
| 75 | """ | |
| 76 | ||
| 38 | 77 | init(client: SRHTClient) { |
| 39 | 78 | self.client = client |
| 40 | 79 | } |
| 41 | 80 | |
| 81 | /// Creates a list. sr.ht subscribes the owner automatically, so a reload is | |
| 82 | /// enough to surface it — this view is built from the subscriptions query. | |
| 83 | @discardableResult | |
| 84 | func createMailingList(name: String, description: String, visibility: Visibility) async -> Bool { | |
| 85 | guard !isPerformingAction else { return false } | |
| 86 | isPerformingAction = true | |
| 87 | error = nil | |
| 88 | defer { isPerformingAction = false } | |
| 89 | ||
| 90 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 91 | let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 92 | ||
| 93 | do { | |
| 94 | struct Response: Decodable, Sendable { | |
| 95 | let createMailingList: InboxMailingListReference | |
| 96 | } | |
| 97 | ||
| 98 | _ = try await client.execute( | |
| 99 | service: .lists, | |
| 100 | query: Self.createMailingListMutation, | |
| 101 | variables: [ | |
| 102 | "name": trimmedName, | |
| 103 | "description": trimmedDescription.isEmpty ? nil as String? as Any : trimmedDescription, | |
| 104 | "visibility": visibility.rawValue | |
| 105 | ], | |
| 106 | responseType: Response.self | |
| 107 | ) | |
| 108 | await loadMailingLists() | |
| 109 | return true | |
| 110 | } catch { | |
| 111 | self.error = "Couldn't create \(trimmedName). \(error.userFacingMessage)" | |
| 112 | return false | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | /// Reads a list's current description and visibility, so the settings sheet | |
| 117 | /// can seed itself rather than overwrite with blanks. | |
| 118 | func listSettings(rid: String) async -> (description: String, visibility: Visibility)? { | |
| 119 | struct Response: Decodable, Sendable { | |
| 120 | let list: ListSettingsPayload? | |
| 121 | } | |
| 122 | ||
| 123 | struct ListSettingsPayload: Decodable, Sendable { | |
| 124 | let description: String? | |
| 125 | let visibility: Visibility | |
| 126 | } | |
| 127 | ||
| 128 | do { | |
| 129 | let response = try await client.execute( | |
| 130 | service: .lists, | |
| 131 | query: Self.listSettingsQuery, | |
| 132 | variables: ["rid": rid], | |
| 133 | responseType: Response.self | |
| 134 | ) | |
| 135 | guard let list = response.list else { return nil } | |
| 136 | return (list.description ?? "", list.visibility) | |
| 137 | } catch { | |
| 138 | self.error = "Couldn't load the list's settings. \(error.userFacingMessage)" | |
| 139 | return nil | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | /// Edits a list's description and visibility. | |
| 144 | /// | |
| 145 | /// `MailingListInput` also carries `permitMime` / `rejectMime`; those are left | |
| 146 | /// alone rather than sent as empty, which would clear the list's filters. | |
| 147 | @discardableResult | |
| 148 | func updateMailingList(id: Int, description: String, visibility: Visibility) async -> Bool { | |
| 149 | guard !isPerformingAction else { return false } | |
| 150 | isPerformingAction = true | |
| 151 | error = nil | |
| 152 | defer { isPerformingAction = false } | |
| 153 | ||
| 154 | let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 155 | var input: [String: any Sendable] = ["visibility": visibility.rawValue] | |
| 156 | if trimmedDescription.isEmpty { | |
| 157 | // A nil subscript assignment would drop the key and leave the old | |
| 158 | // description in place instead of clearing it. | |
| 159 | input.updateValue(Optional<String>.none as any Sendable, forKey: "description") | |
| 160 | } else { | |
| 161 | input["description"] = trimmedDescription | |
| 162 | } | |
| 163 | ||
| 164 | do { | |
| 165 | struct Response: Decodable, Sendable { | |
| 166 | let updateMailingList: ListIDPayload? | |
| 167 | } | |
| 168 | ||
| 169 | _ = try await client.execute( | |
| 170 | service: .lists, | |
| 171 | query: Self.updateMailingListMutation, | |
| 172 | variables: ["id": id, "input": input], | |
| 173 | responseType: Response.self | |
| 174 | ) | |
| 175 | await loadMailingLists() | |
| 176 | return true | |
| 177 | } catch { | |
| 178 | self.error = "Couldn't update the list. \(error.userFacingMessage)" | |
| 179 | return false | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | @discardableResult | |
| 184 | func deleteMailingList(_ mailingList: InboxMailingListReference) async -> Bool { | |
| 185 | guard !isPerformingAction else { return false } | |
| 186 | isPerformingAction = true | |
| 187 | error = nil | |
| 188 | defer { isPerformingAction = false } | |
| 189 | ||
| 190 | let previousLists = mailingLists | |
| 191 | mailingLists.removeAll { $0.rid == mailingList.rid } | |
| 192 | ||
| 193 | do { | |
| 194 | struct Response: Decodable, Sendable { | |
| 195 | let deleteMailingList: ListIDPayload? | |
| 196 | } | |
| 197 | ||
| 198 | _ = try await client.execute( | |
| 199 | service: .lists, | |
| 200 | query: Self.deleteMailingListMutation, | |
| 201 | variables: ["id": mailingList.id], | |
| 202 | responseType: Response.self | |
| 203 | ) | |
| 204 | return true | |
| 205 | } catch { | |
| 206 | mailingLists = previousLists | |
| 207 | self.error = "Couldn't delete \(mailingList.name). \(error.userFacingMessage)" | |
| 208 | return false | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 42 | 212 | /// Unsubscribes from a list and drops it from the list on success. This view |
| 43 | 213 | /// is built from the subscriptions query, so a successful unsubscribe means |
| 44 | 214 | /// the row no longer belongs here. |
| @@ -149,6 +319,19 @@ struct MailingListListView: View { | ||
| 149 | 319 | @Environment(AppState.self) private var appState |
| 150 | 320 | @State private var viewModel: MailingListListViewModel? |
| 151 | 321 | @State private var pendingUnsubscribe: InboxMailingListReference? |
| 322 | @State private var pendingDeletion: InboxMailingListReference? | |
| 323 | @State private var editingList: InboxMailingListReference? | |
| 324 | @State private var showCreateSheet = false | |
| 325 | ||
| 326 | /// The subscriptions query returns lists the user follows, which is not the | |
| 327 | /// same as lists they own — only the owner may edit or delete one. | |
| 328 | private func isOwned(_ mailingList: InboxMailingListReference) -> Bool { | |
| 329 | guard let currentUser = appState.currentUser else { return false } | |
| 330 | let owner = mailingList.owner.canonicalName.hasPrefix("~") | |
| 331 | ? String(mailingList.owner.canonicalName.dropFirst()) | |
| 332 | : mailingList.owner.canonicalName | |
| 333 | return owner.caseInsensitiveCompare(currentUser.username) == .orderedSame | |
| 334 | } | |
| 152 | 335 | |
| 153 | 336 | var body: some View { |
| 154 | 337 | Group { |
| @@ -185,12 +368,26 @@ struct MailingListListView: View { | ||
| 185 | 368 | .padding(.vertical, 2) |
| 186 | 369 | } |
| 187 | 370 | .swipeActions(edge: .trailing) { |
| 188 | Button { | |
| 189 | pendingUnsubscribe = mailingList | |
| 190 | } label: { | |
| 191 | SwiftUI.Label("Unsubscribe", systemImage: "bell.slash") | |
| 371 | if isOwned(mailingList) { | |
| 372 | Button(role: .destructive) { | |
| 373 | pendingDeletion = mailingList | |
| 374 | } label: { | |
| 375 | SwiftUI.Label("Delete", systemImage: "trash") | |
| 376 | } | |
| 377 | Button { | |
| 378 | editingList = mailingList | |
| 379 | } label: { | |
| 380 | SwiftUI.Label("Settings", systemImage: "gear") | |
| 381 | } | |
| 382 | .tint(.gray) | |
| 383 | } else { | |
| 384 | Button { | |
| 385 | pendingUnsubscribe = mailingList | |
| 386 | } label: { | |
| 387 | SwiftUI.Label("Unsubscribe", systemImage: "bell.slash") | |
| 388 | } | |
| 389 | .tint(.orange) | |
| 192 | 390 | } |
| 193 | .tint(.orange) | |
| 194 | 391 | } |
| 195 | 392 | } |
| 196 | 393 | .themedRow() |
| @@ -218,6 +415,46 @@ struct MailingListListView: View { | ||
| 218 | 415 | } message: { _ in |
| 219 | 416 | 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.") |
| 220 | 417 | } |
| 418 | .toolbar { | |
| 419 | ToolbarItem(placement: .topBarTrailing) { | |
| 420 | Button { | |
| 421 | showCreateSheet = true | |
| 422 | } label: { | |
| 423 | SwiftUI.Label("New List", systemImage: "plus") | |
| 424 | } | |
| 425 | .disabled(viewModel.isPerformingAction) | |
| 426 | } | |
| 427 | } | |
| 428 | .sheet(isPresented: $showCreateSheet) { | |
| 429 | MailingListEditSheet(mode: .create, isPresented: $showCreateSheet) { name, description, visibility in | |
| 430 | await viewModel.createMailingList(name: name, description: description, visibility: visibility) | |
| 431 | } | |
| 432 | } | |
| 433 | .sheet(item: $editingList) { mailingList in | |
| 434 | MailingListEditSheet( | |
| 435 | mode: .edit(mailingList.name), | |
| 436 | isPresented: .init(get: { true }, set: { if !$0 { editingList = nil } }), | |
| 437 | loadInitialValues: { await viewModel.listSettings(rid: mailingList.rid) } | |
| 438 | ) { _, description, visibility in | |
| 439 | await viewModel.updateMailingList(id: mailingList.id, description: description, visibility: visibility) | |
| 440 | } | |
| 441 | } | |
| 442 | .confirmationDialog( | |
| 443 | pendingDeletion.map { "Delete \($0.name)?" } ?? "", | |
| 444 | isPresented: .init( | |
| 445 | get: { pendingDeletion != nil }, | |
| 446 | set: { if !$0 { pendingDeletion = nil } } | |
| 447 | ), | |
| 448 | titleVisibility: .visible, | |
| 449 | presenting: pendingDeletion | |
| 450 | ) { mailingList in | |
| 451 | Button("Delete List", role: .destructive) { | |
| 452 | Task { await viewModel.deleteMailingList(mailingList) } | |
| 453 | } | |
| 454 | Button("Cancel", role: .cancel) { pendingDeletion = nil } | |
| 455 | } message: { _ in | |
| 456 | Text("This permanently deletes the list and its entire archive, for everyone. This cannot be undone.") | |
| 457 | } | |
| 221 | 458 | .overlay { |
| 222 | 459 | if viewModel.isLoading, viewModel.mailingLists.isEmpty { |
| 223 | 460 | SRHTLoadingStateView(message: "Loading mailing lists…") |
| @@ -243,3 +480,117 @@ struct MailingListListView: View { | ||
| 243 | 480 | } |
| 244 | 481 | } |
| 245 | 482 | } |
| 483 | ||
| 484 | // MARK: - Edit Sheet | |
| 485 | ||
| 486 | /// Create and settings share a sheet: sr.ht takes name only at creation, and | |
| 487 | /// description plus visibility in both cases. | |
| 488 | private struct MailingListEditSheet: View { | |
| 489 | enum Mode { | |
| 490 | case create | |
| 491 | case edit(String) | |
| 492 | ||
| 493 | var title: String { | |
| 494 | switch self { | |
| 495 | case .create: "New Mailing List" | |
| 496 | case .edit(let name): name | |
| 497 | } | |
| 498 | } | |
| 499 | ||
| 500 | var isCreate: Bool { | |
| 501 | if case .create = self { return true } | |
| 502 | return false | |
| 503 | } | |
| 504 | } | |
| 505 | ||
| 506 | let mode: Mode | |
| 507 | @Binding var isPresented: Bool | |
| 508 | /// Seeds the sheet with the list's current values. Editing without this would | |
| 509 | /// save blanks over whatever is already there. | |
| 510 | var loadInitialValues: (() async -> (description: String, visibility: Visibility)?)? | |
| 511 | let onSubmit: (String, String, Visibility) async -> Bool | |
| 512 | ||
| 513 | @State private var name = "" | |
| 514 | @State private var description = "" | |
| 515 | @State private var visibility: Visibility = .publicVisibility | |
| 516 | @State private var isSubmitting = false | |
| 517 | @State private var isLoadingInitialValues = false | |
| 518 | @State private var hasLoadedInitialValues = false | |
| 519 | ||
| 520 | private var trimmedName: String { | |
| 521 | name.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 522 | } | |
| 523 | ||
| 524 | private var canSubmit: Bool { | |
| 525 | guard !isSubmitting, !isLoadingInitialValues else { return false } | |
| 526 | if mode.isCreate { return !trimmedName.isEmpty } | |
| 527 | // Never offer to save values we have not read back yet. | |
| 528 | return hasLoadedInitialValues | |
| 529 | } | |
| 530 | ||
| 531 | var body: some View { | |
| 532 | NavigationStack { | |
| 533 | Form { | |
| 534 | if mode.isCreate { | |
| 535 | Section("Name") { | |
| 536 | TextField("list-name", text: $name) | |
| 537 | .textInputAutocapitalization(.never) | |
| 538 | .autocorrectionDisabled() | |
| 539 | .themedRow() | |
| 540 | } | |
| 541 | } | |
| 542 | ||
| 543 | Section("Description") { | |
| 544 | TextField("Description", text: $description, axis: .vertical) | |
| 545 | .lineLimit(2...6) | |
| 546 | .themedRow() | |
| 547 | } | |
| 548 | ||
| 549 | Section("Visibility") { | |
| 550 | Picker("Visibility", selection: $visibility) { | |
| 551 | Text("Public").tag(Visibility.publicVisibility) | |
| 552 | Text("Unlisted").tag(Visibility.unlisted) | |
| 553 | Text("Private").tag(Visibility.privateVisibility) | |
| 554 | } | |
| 555 | .pickerStyle(.inline) | |
| 556 | .labelsHidden() | |
| 557 | .themedRow() | |
| 558 | } | |
| 559 | } | |
| 560 | .themedList() | |
| 561 | .navigationTitle(mode.title) | |
| 562 | .navigationBarTitleDisplayMode(.inline) | |
| 563 | .task { | |
| 564 | guard let loadInitialValues, !hasLoadedInitialValues else { return } | |
| 565 | isLoadingInitialValues = true | |
| 566 | if let current = await loadInitialValues() { | |
| 567 | description = current.description | |
| 568 | visibility = current.visibility | |
| 569 | hasLoadedInitialValues = true | |
| 570 | } | |
| 571 | isLoadingInitialValues = false | |
| 572 | } | |
| 573 | .toolbar { | |
| 574 | ToolbarItem(placement: .cancellationAction) { | |
| 575 | Button("Cancel") { isPresented = false } | |
| 576 | } | |
| 577 | ToolbarItem(placement: .confirmationAction) { | |
| 578 | Button(mode.isCreate ? "Create" : "Save") { | |
| 579 | Task { | |
| 580 | isSubmitting = true | |
| 581 | let ok = await onSubmit(trimmedName, description, visibility) | |
| 582 | isSubmitting = false | |
| 583 | if ok { isPresented = false } | |
| 584 | } | |
| 585 | } | |
| 586 | .disabled(!canSubmit) | |
| 587 | } | |
| 588 | } | |
| 589 | .overlay { | |
| 590 | if isSubmitting || isLoadingInitialValues { | |
| 591 | ProgressView() | |
| 592 | } | |
| 593 | } | |
| 594 | } | |
| 595 | } | |
| 596 | } | |