krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.6.0: Hutch/Views/Lists/MailingListListView.swift · raw

  1import SwiftUI
  2
  3@Observable
  4@MainActor
  5final class MailingListListViewModel {
  6    private(set) var mailingLists: [InboxMailingListReference] = []
  7    private(set) var isLoading = false
  8    private(set) var isPerformingAction = false
  9    var error: String?
 10    var searchText = ""
 11
 12    private let client: SRHTClient
 13
 14    private static let subscriptionsQuery = """
 15    query mailingLists($cursor: Cursor) {
 16        subscriptions(cursor: $cursor) {
 17            results {
 18                ... on MailingListSubscription {
 19                    list {
 20                        id
 21                        rid
 22                        name
 23                        owner { canonicalName }
 24                    }
 25                }
 26            }
 27            cursor
 28        }
 29    }
 30    """
 31
 32    private static let unsubscribeMutation = """
 33    mutation mailingListUnsubscribe($listID: Int!) {
 34        subscription: mailingListUnsubscribe(listID: $listID) { id }
 35    }
 36    """
 37
 38    init(client: SRHTClient) {
 39        self.client = client
 40    }
 41
 42    /// 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
 44    /// the row no longer belongs here.
 45    func unsubscribe(from mailingList: InboxMailingListReference) async {
 46        guard !isPerformingAction else { return }
 47        isPerformingAction = true
 48        error = nil
 49        defer { isPerformingAction = false }
 50
 51        let previousLists = mailingLists
 52        mailingLists.removeAll { $0.rid == mailingList.rid }
 53
 54        do {
 55            struct Response: Decodable, Sendable {
 56                // mailingListUnsubscribe is nullable: sr.ht returns null when there
 57                // was no subscription to remove, which is still a success.
 58                let subscription: SubscriptionPayload?
 59            }
 60
 61            struct SubscriptionPayload: Decodable, Sendable {
 62                let id: Int
 63            }
 64
 65            _ = try await client.execute(
 66                service: .lists,
 67                query: Self.unsubscribeMutation,
 68                variables: ["listID": mailingList.id],
 69                responseType: Response.self
 70            )
 71        } catch {
 72            mailingLists = previousLists
 73            self.error = "Couldn't unsubscribe from \(mailingList.name). \(error.userFacingMessage)"
 74        }
 75    }
 76
 77    var filteredMailingLists: [InboxMailingListReference] {
 78        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
 79        guard !q.isEmpty else { return mailingLists }
 80        return mailingLists.filter {
 81            $0.name.lowercased().contains(q) ||
 82            $0.owner.canonicalName.lowercased().contains(q)
 83        }
 84    }
 85
 86    func loadMailingLists() async {
 87        guard !isLoading else { return }
 88        isLoading = true
 89        error = nil
 90        defer { isLoading = false }
 91
 92        do {
 93            mailingLists = try await fetchMailingLists()
 94        } catch {
 95            self.error = "Failed to load mailing lists"
 96        }
 97    }
 98
 99    private func fetchMailingLists() async throws -> [InboxMailingListReference] {
100        struct Response: Decodable, Sendable {
101            let subscriptions: Page
102        }
103
104        struct Page: Decodable, Sendable {
105            let results: [Subscription]
106            let cursor: String?
107        }
108
109        struct Subscription: Decodable, Sendable {
110            let list: InboxMailingListReference?
111        }
112
113        var results: [InboxMailingListReference] = []
114        var cursor: String?
115
116        while true {
117            var variables: [String: any Sendable] = [:]
118            if let cursor {
119                variables["cursor"] = cursor
120            }
121
122            let response = try await client.execute(
123                service: .lists,
124                query: Self.subscriptionsQuery,
125                variables: variables.isEmpty ? nil : variables,
126                responseType: Response.self
127            )
128
129            results.append(contentsOf: response.subscriptions.results.compactMap(\.list))
130            guard let nextCursor = response.subscriptions.cursor else {
131                break
132            }
133            cursor = nextCursor
134        }
135
136        var seen = Set<String>()
137        return results
138            .filter { seen.insert($0.rid).inserted }
139            .sorted {
140                if $0.owner.canonicalName == $1.owner.canonicalName {
141                    return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
142                }
143                return $0.owner.canonicalName.localizedCaseInsensitiveCompare($1.owner.canonicalName) == .orderedAscending
144            }
145    }
146}
147
148struct MailingListListView: View {
149    @Environment(AppState.self) private var appState
150    @State private var viewModel: MailingListListViewModel?
151    @State private var pendingUnsubscribe: InboxMailingListReference?
152
153    var body: some View {
154        Group {
155            if let viewModel {
156                content(viewModel)
157            } else {
158                SRHTLoadingStateView(message: "Loading mailing lists…")
159            }
160        }
161        .navigationTitle("Mailing Lists")
162        .task {
163            if viewModel == nil {
164                let vm = MailingListListViewModel(client: appState.client)
165                viewModel = vm
166                await vm.loadMailingLists()
167            }
168        }
169    }
170
171    @ViewBuilder
172    private func content(_ viewModel: MailingListListViewModel) -> some View {
173        @Bindable var vm = viewModel
174
175        List {
176            ForEach(viewModel.filteredMailingLists, id: \.rid) { mailingList in
177                NavigationLink(value: MoreRoute.mailingList(mailingList)) {
178                    VStack(alignment: .leading, spacing: 4) {
179                        Text(mailingList.name)
180                            .font(.subheadline.weight(.medium))
181                        Text(mailingList.owner.canonicalName)
182                            .font(.caption)
183                            .foregroundStyle(.secondary)
184                    }
185                    .padding(.vertical, 2)
186                }
187                .swipeActions(edge: .trailing) {
188                    Button {
189                        pendingUnsubscribe = mailingList
190                    } label: {
191                        SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
192                    }
193                    .tint(.orange)
194                }
195            }
196            .themedRow()
197        }
198        .themedList()
199        .listStyle(.plain)
200        .searchable(
201            text: $vm.searchText,
202            placement: .navigationBarDrawer(displayMode: .always),
203            prompt: "Search lists"
204        )
205        .confirmationDialog(
206            pendingUnsubscribe.map { "Unsubscribe from \($0.name)?" } ?? "",
207            isPresented: .init(
208                get: { pendingUnsubscribe != nil },
209                set: { if !$0 { pendingUnsubscribe = nil } }
210            ),
211            titleVisibility: .visible,
212            presenting: pendingUnsubscribe
213        ) { mailingList in
214            Button("Unsubscribe", role: .destructive) {
215                Task { await viewModel.unsubscribe(from: mailingList) }
216            }
217            Button("Cancel", role: .cancel) { pendingUnsubscribe = nil }
218        } 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.")
220        }
221        .overlay {
222            if viewModel.isLoading, viewModel.mailingLists.isEmpty {
223                SRHTLoadingStateView(message: "Loading mailing lists…")
224            } else if let error = viewModel.error, viewModel.mailingLists.isEmpty {
225                SRHTErrorStateView(
226                    title: "Couldn't Load Mailing Lists",
227                    message: error,
228                    retryAction: { await viewModel.loadMailingLists() }
229                )
230            } else if !viewModel.mailingLists.isEmpty, viewModel.filteredMailingLists.isEmpty {
231                ContentUnavailableView.search(text: viewModel.searchText)
232            } else if viewModel.mailingLists.isEmpty {
233                ContentUnavailableView(
234                    "No Mailing Lists",
235                    systemImage: "list.bullet.rectangle",
236                    description: Text("Your subscribed mailing lists will appear here.")
237                )
238            }
239        }
240        .srhtErrorBanner(error: $vm.error)
241        .refreshable {
242            await viewModel.loadMailingLists()
243        }
244    }
245}