krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: Hutch/Views/Lists/MailingListListView.swift · raw
1import SwiftUI
2
3private struct ListIDPayload: Decodable, Sendable {
4 let id: Int
5}
6
7@Observable
8@MainActor
9final class MailingListListViewModel {
10 private(set) var mailingLists: [InboxMailingListReference] = []
11 private(set) var isLoading = false
12 private(set) var isPerformingAction = false
13 var error: String?
14 var searchText = ""
15
16 private let client: SRHTClient
17
18 private static let subscriptionsQuery = """
19 query mailingLists($cursor: Cursor) {
20 subscriptions(cursor: $cursor) {
21 results {
22 ... on MailingListSubscription {
23 list {
24 id
25 rid
26 name
27 owner { canonicalName }
28 }
29 }
30 }
31 cursor
32 }
33 }
34 """
35
36 private static let unsubscribeMutation = """
37 mutation mailingListUnsubscribe($listID: Int!) {
38 subscription: mailingListUnsubscribe(listID: $listID) { id }
39 }
40 """
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
77 init(client: SRHTClient) {
78 self.client = client
79 }
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 Sendable : 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
212 /// Unsubscribes from a list and drops it from the list on success. This view
213 /// is built from the subscriptions query, so a successful unsubscribe means
214 /// the row no longer belongs here.
215 func unsubscribe(from mailingList: InboxMailingListReference) async {
216 guard !isPerformingAction else { return }
217 isPerformingAction = true
218 error = nil
219 defer { isPerformingAction = false }
220
221 let previousLists = mailingLists
222 mailingLists.removeAll { $0.rid == mailingList.rid }
223
224 do {
225 struct Response: Decodable, Sendable {
226 // mailingListUnsubscribe is nullable: sr.ht returns null when there
227 // was no subscription to remove, which is still a success.
228 let subscription: SubscriptionPayload?
229 }
230
231 struct SubscriptionPayload: Decodable, Sendable {
232 let id: Int
233 }
234
235 _ = try await client.execute(
236 service: .lists,
237 query: Self.unsubscribeMutation,
238 variables: ["listID": mailingList.id],
239 responseType: Response.self
240 )
241 } catch {
242 mailingLists = previousLists
243 self.error = "Couldn't unsubscribe from \(mailingList.name). \(error.userFacingMessage)"
244 }
245 }
246
247 var filteredMailingLists: [InboxMailingListReference] {
248 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
249 guard !q.isEmpty else { return mailingLists }
250 return mailingLists.filter {
251 $0.name.lowercased().contains(q) ||
252 $0.owner.canonicalName.lowercased().contains(q)
253 }
254 }
255
256 func loadMailingLists() async {
257 guard !isLoading else { return }
258 isLoading = true
259 error = nil
260 defer { isLoading = false }
261
262 do {
263 mailingLists = try await fetchMailingLists()
264 } catch {
265 self.error = "Failed to load mailing lists"
266 }
267 }
268
269 private func fetchMailingLists() async throws -> [InboxMailingListReference] {
270 struct Response: Decodable, Sendable {
271 let subscriptions: Page
272 }
273
274 struct Page: Decodable, Sendable {
275 let results: [Subscription]
276 let cursor: String?
277 }
278
279 struct Subscription: Decodable, Sendable {
280 let list: InboxMailingListReference?
281 }
282
283 var results: [InboxMailingListReference] = []
284 var cursor: String?
285
286 while true {
287 var variables: [String: any Sendable] = [:]
288 if let cursor {
289 variables["cursor"] = cursor
290 }
291
292 let response = try await client.execute(
293 service: .lists,
294 query: Self.subscriptionsQuery,
295 variables: variables.isEmpty ? nil : variables,
296 responseType: Response.self
297 )
298
299 results.append(contentsOf: response.subscriptions.results.compactMap(\.list))
300 guard let nextCursor = response.subscriptions.cursor else {
301 break
302 }
303 cursor = nextCursor
304 }
305
306 var seen = Set<String>()
307 return results
308 .filter { seen.insert($0.rid).inserted }
309 .sorted {
310 if $0.owner.canonicalName == $1.owner.canonicalName {
311 return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
312 }
313 return $0.owner.canonicalName.localizedCaseInsensitiveCompare($1.owner.canonicalName) == .orderedAscending
314 }
315 }
316}
317
318struct MailingListListView: View {
319 @Environment(AppState.self) private var appState
320 @State private var viewModel: MailingListListViewModel?
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 }
335
336 var body: some View {
337 Group {
338 if let viewModel {
339 content(viewModel)
340 } else {
341 SRHTLoadingStateView(message: "Loading mailing lists…")
342 }
343 }
344 .navigationTitle("Mailing Lists")
345 .task {
346 if viewModel == nil {
347 let vm = MailingListListViewModel(client: appState.client)
348 viewModel = vm
349 await vm.loadMailingLists()
350 }
351 }
352 }
353
354 @ViewBuilder
355 private func content(_ viewModel: MailingListListViewModel) -> some View {
356 @Bindable var vm = viewModel
357
358 List {
359 ForEach(viewModel.filteredMailingLists, id: \.rid) { mailingList in
360 NavigationLink(value: MoreRoute.mailingList(mailingList)) {
361 VStack(alignment: .leading, spacing: 4) {
362 Text(mailingList.name)
363 .font(.subheadline.weight(.medium))
364 Text(mailingList.owner.canonicalName)
365 .font(.caption)
366 .foregroundStyle(.secondary)
367 }
368 .padding(.vertical, 2)
369 }
370 // allowsFullSwipe: false, as in PasteListView. A destructive
371 // action left to full-swipe animates the row out on the gesture,
372 // before the confirmation is answered, so it flickers back when
373 // the data has not actually changed.
374 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
375 if isOwned(mailingList) {
376 Button {
377 pendingDeletion = mailingList
378 } label: {
379 SwiftUI.Label("Delete", systemImage: "trash")
380 }
381 .tint(.red)
382 Button {
383 editingList = mailingList
384 } label: {
385 SwiftUI.Label("Settings", systemImage: "gear")
386 }
387 .tint(.gray)
388 } else {
389 Button {
390 pendingUnsubscribe = mailingList
391 } label: {
392 SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
393 }
394 .tint(.orange)
395 }
396 }
397 }
398 .themedRow()
399 }
400 .themedList()
401 .listStyle(.plain)
402 .searchable(
403 text: $vm.searchText,
404 placement: .navigationBarDrawer(displayMode: .always),
405 prompt: "Search lists"
406 )
407 .confirmationDialog(
408 pendingUnsubscribe.map { "Unsubscribe from \($0.name)?" } ?? "",
409 isPresented: .init(
410 get: { pendingUnsubscribe != nil },
411 set: { if !$0 { pendingUnsubscribe = nil } }
412 ),
413 titleVisibility: .visible,
414 presenting: pendingUnsubscribe
415 ) { mailingList in
416 Button("Unsubscribe", role: .destructive) {
417 Task { await viewModel.unsubscribe(from: mailingList) }
418 }
419 Button("Cancel", role: .cancel) { pendingUnsubscribe = nil }
420 } message: { _ in
421 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.")
422 }
423 .toolbar {
424 ToolbarItem(placement: .topBarTrailing) {
425 Button {
426 showCreateSheet = true
427 } label: {
428 SwiftUI.Label("New List", systemImage: "plus")
429 }
430 .disabled(viewModel.isPerformingAction)
431 }
432 }
433 .sheet(isPresented: $showCreateSheet) {
434 MailingListEditSheet(mode: .create, isPresented: $showCreateSheet) { name, description, visibility in
435 await viewModel.createMailingList(name: name, description: description, visibility: visibility)
436 }
437 }
438 .sheet(item: $editingList) { mailingList in
439 MailingListEditSheet(
440 mode: .edit(mailingList.name),
441 isPresented: .init(get: { true }, set: { if !$0 { editingList = nil } }),
442 loadInitialValues: { await viewModel.listSettings(rid: mailingList.rid) }
443 ) { _, description, visibility in
444 await viewModel.updateMailingList(id: mailingList.id, description: description, visibility: visibility)
445 }
446 }
447 .confirmationDialog(
448 pendingDeletion.map { "Delete \($0.name)?" } ?? "",
449 isPresented: .init(
450 get: { pendingDeletion != nil },
451 set: { if !$0 { pendingDeletion = nil } }
452 ),
453 titleVisibility: .visible,
454 presenting: pendingDeletion
455 ) { mailingList in
456 Button("Delete List", role: .destructive) {
457 Task { await viewModel.deleteMailingList(mailingList) }
458 }
459 Button("Cancel", role: .cancel) { pendingDeletion = nil }
460 } message: { _ in
461 Text("This permanently deletes the list and its entire archive, for everyone. This cannot be undone.")
462 }
463 .overlay {
464 if viewModel.isLoading, viewModel.mailingLists.isEmpty {
465 SRHTLoadingStateView(message: "Loading mailing lists…")
466 } else if let error = viewModel.error, viewModel.mailingLists.isEmpty {
467 SRHTErrorStateView(
468 title: "Couldn't Load Mailing Lists",
469 message: error,
470 retryAction: { await viewModel.loadMailingLists() }
471 )
472 } else if !viewModel.mailingLists.isEmpty, viewModel.filteredMailingLists.isEmpty {
473 ContentUnavailableView.search(text: viewModel.searchText)
474 } else if viewModel.mailingLists.isEmpty {
475 ContentUnavailableView(
476 "No Mailing Lists",
477 systemImage: "list.bullet.rectangle",
478 description: Text("Your subscribed mailing lists will appear here.")
479 )
480 }
481 }
482 .srhtErrorBanner(error: $vm.error)
483 .refreshable {
484 await viewModel.loadMailingLists()
485 }
486 }
487}
488
489// MARK: - Edit Sheet
490
491/// Create and settings share a sheet: sr.ht takes name only at creation, and
492/// description plus visibility in both cases.
493private struct MailingListEditSheet: View {
494 enum Mode {
495 case create
496 case edit(String)
497
498 var title: String {
499 switch self {
500 case .create: "New Mailing List"
501 case .edit(let name): name
502 }
503 }
504
505 var isCreate: Bool {
506 if case .create = self { return true }
507 return false
508 }
509 }
510
511 let mode: Mode
512 @Binding var isPresented: Bool
513 /// Seeds the sheet with the list's current values. Editing without this would
514 /// save blanks over whatever is already there.
515 var loadInitialValues: (() async -> (description: String, visibility: Visibility)?)?
516 let onSubmit: (String, String, Visibility) async -> Bool
517
518 @State private var name = ""
519 @State private var description = ""
520 @State private var visibility: Visibility = .publicVisibility
521 @State private var isSubmitting = false
522 @State private var isLoadingInitialValues = false
523 @State private var hasLoadedInitialValues = false
524
525 private var trimmedName: String {
526 name.trimmingCharacters(in: .whitespacesAndNewlines)
527 }
528
529 private var canSubmit: Bool {
530 guard !isSubmitting, !isLoadingInitialValues else { return false }
531 if mode.isCreate { return !trimmedName.isEmpty }
532 // Never offer to save values we have not read back yet.
533 return hasLoadedInitialValues
534 }
535
536 var body: some View {
537 NavigationStack {
538 Form {
539 if mode.isCreate {
540 Section("Name") {
541 TextField("list-name", text: $name)
542 .textInputAutocapitalization(.never)
543 .autocorrectionDisabled()
544 .themedRow()
545 }
546 }
547
548 Section("Description") {
549 TextField("Description", text: $description, axis: .vertical)
550 .lineLimit(2...6)
551 .themedRow()
552 }
553
554 Section("Visibility") {
555 Picker("Visibility", selection: $visibility) {
556 Text("Public").tag(Visibility.publicVisibility)
557 Text("Unlisted").tag(Visibility.unlisted)
558 Text("Private").tag(Visibility.privateVisibility)
559 }
560 .pickerStyle(.inline)
561 .labelsHidden()
562 .themedRow()
563 }
564 }
565 .themedList()
566 .navigationTitle(mode.title)
567 .navigationBarTitleDisplayMode(.inline)
568 .task {
569 guard let loadInitialValues, !hasLoadedInitialValues else { return }
570 isLoadingInitialValues = true
571 if let current = await loadInitialValues() {
572 description = current.description
573 visibility = current.visibility
574 hasLoadedInitialValues = true
575 }
576 isLoadingInitialValues = false
577 }
578 .toolbar {
579 ToolbarItem(placement: .cancellationAction) {
580 Button("Cancel") { isPresented = false }
581 }
582 ToolbarItem(placement: .confirmationAction) {
583 Button(mode.isCreate ? "Create" : "Save") {
584 Task {
585 isSubmitting = true
586 let ok = await onSubmit(trimmedName, description, visibility)
587 isSubmitting = false
588 if ok { isPresented = false }
589 }
590 }
591 .disabled(!canSubmit)
592 }
593 }
594 .overlay {
595 if isSubmitting || isLoadingInitialValues {
596 ProgressView()
597 }
598 }
599 }
600 }
601}