Commit 1d894ad72b

1d894ad72bcf82725b6c962feb7a402954de3419

parent: eed03517da

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 04:58 UTC

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.

Layout: unified · split

Hutch/Views/Lists/MailingListListView.swift +356 −5
@@ -1,5 +1,9 @@
1import SwiftUI 1import SwiftUI
2 2
3private struct ListIDPayload: Decodable, Sendable {
4 let id: Int
5}
6
3@Observable 7@Observable
4@MainActor 8@MainActor
5final class MailingListListViewModel { 9final 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.
488private 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}