Commit 1d894ad72b
Unsigned
Layout: unified · split
Hutch/Views/Lists/MailingListListView.swift +356 −5
| @@ -1,5 +1,9 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | private struct ListIDPayload: Decodable, Sendable { | ||
| 4 | let id: Int | ||
| 5 | } | ||
| 6 | |||
| 3 | @Observable | 7 | @Observable |
| 4 | @MainActor | 8 | @MainActor |
| 5 | final class MailingListListViewModel { | 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 | init(client: SRHTClient) { | 77 | init(client: SRHTClient) { |
| 39 | self.client = client | 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 | /// Unsubscribes from a list and drops it from the list on success. This view | 212 | /// Unsubscribes from a list and drops it from the list on success. This view |
| 43 | /// is built from the subscriptions query, so a successful unsubscribe means | 213 | /// is built from the subscriptions query, so a successful unsubscribe means |
| 44 | /// the row no longer belongs here. | 214 | /// the row no longer belongs here. |
| @@ -149,6 +319,19 @@ struct MailingListListView: View { | |||
| 149 | @Environment(AppState.self) private var appState | 319 | @Environment(AppState.self) private var appState |
| 150 | @State private var viewModel: MailingListListViewModel? | 320 | @State private var viewModel: MailingListListViewModel? |
| 151 | @State private var pendingUnsubscribe: InboxMailingListReference? | 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 | var body: some View { | 336 | var body: some View { |
| 154 | Group { | 337 | Group { |
| @@ -185,12 +368,26 @@ struct MailingListListView: View { | |||
| 185 | .padding(.vertical, 2) | 368 | .padding(.vertical, 2) |
| 186 | } | 369 | } |
| 187 | .swipeActions(edge: .trailing) { | 370 | .swipeActions(edge: .trailing) { |
| 188 | Button { | 371 | if isOwned(mailingList) { |
| 189 | pendingUnsubscribe = mailingList | 372 | Button(role: .destructive) { |
| 190 | } label: { | 373 | pendingDeletion = mailingList |
| 191 | SwiftUI.Label("Unsubscribe", systemImage: "bell.slash") | 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 | .themedRow() | 393 | .themedRow() |
| @@ -218,6 +415,46 @@ struct MailingListListView: View { | |||
| 218 | } message: { _ in | 415 | } message: { _ in |
| 219 | 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.") | 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 | .overlay { | 458 | .overlay { |
| 222 | if viewModel.isLoading, viewModel.mailingLists.isEmpty { | 459 | if viewModel.isLoading, viewModel.mailingLists.isEmpty { |
| 223 | SRHTLoadingStateView(message: "Loading mailing lists…") | 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 | } | ||