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}