krz/hutch

an ios client for sourcehut

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

v3.2.1: 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    var error: String?
  9    var searchText = ""
 10
 11    private let client: SRHTClient
 12
 13    private static let subscriptionsQuery = """
 14    query mailingLists($cursor: Cursor) {
 15        subscriptions(cursor: $cursor) {
 16            results {
 17                ... on MailingListSubscription {
 18                    list {
 19                        id
 20                        rid
 21                        name
 22                        owner { canonicalName }
 23                    }
 24                }
 25            }
 26            cursor
 27        }
 28    }
 29    """
 30
 31    init(client: SRHTClient) {
 32        self.client = client
 33    }
 34
 35    var filteredMailingLists: [InboxMailingListReference] {
 36        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
 37        guard !q.isEmpty else { return mailingLists }
 38        return mailingLists.filter {
 39            $0.name.lowercased().contains(q) ||
 40            $0.owner.canonicalName.lowercased().contains(q)
 41        }
 42    }
 43
 44    func loadMailingLists() async {
 45        guard !isLoading else { return }
 46        isLoading = true
 47        error = nil
 48        defer { isLoading = false }
 49
 50        do {
 51            mailingLists = try await fetchMailingLists()
 52        } catch {
 53            self.error = "Failed to load mailing lists"
 54        }
 55    }
 56
 57    private func fetchMailingLists() async throws -> [InboxMailingListReference] {
 58        struct Response: Decodable, Sendable {
 59            let subscriptions: Page
 60        }
 61
 62        struct Page: Decodable, Sendable {
 63            let results: [Subscription]
 64            let cursor: String?
 65        }
 66
 67        struct Subscription: Decodable, Sendable {
 68            let list: InboxMailingListReference?
 69        }
 70
 71        var results: [InboxMailingListReference] = []
 72        var cursor: String?
 73
 74        while true {
 75            var variables: [String: any Sendable] = [:]
 76            if let cursor {
 77                variables["cursor"] = cursor
 78            }
 79
 80            let response = try await client.execute(
 81                service: .lists,
 82                query: Self.subscriptionsQuery,
 83                variables: variables.isEmpty ? nil : variables,
 84                responseType: Response.self
 85            )
 86
 87            results.append(contentsOf: response.subscriptions.results.compactMap(\.list))
 88            guard let nextCursor = response.subscriptions.cursor else {
 89                break
 90            }
 91            cursor = nextCursor
 92        }
 93
 94        var seen = Set<String>()
 95        return results
 96            .filter { seen.insert($0.rid).inserted }
 97            .sorted {
 98                if $0.owner.canonicalName == $1.owner.canonicalName {
 99                    return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
100                }
101                return $0.owner.canonicalName.localizedCaseInsensitiveCompare($1.owner.canonicalName) == .orderedAscending
102            }
103    }
104}
105
106struct MailingListListView: View {
107    @Environment(AppState.self) private var appState
108    @State private var viewModel: MailingListListViewModel?
109
110    var body: some View {
111        Group {
112            if let viewModel {
113                content(viewModel)
114            } else {
115                SRHTLoadingStateView(message: "Loading mailing lists…")
116            }
117        }
118        .navigationTitle("Mailing Lists")
119        .task {
120            if viewModel == nil {
121                let vm = MailingListListViewModel(client: appState.client)
122                viewModel = vm
123                await vm.loadMailingLists()
124            }
125        }
126    }
127
128    @ViewBuilder
129    private func content(_ viewModel: MailingListListViewModel) -> some View {
130        @Bindable var vm = viewModel
131
132        List {
133            ForEach(viewModel.filteredMailingLists, id: \.rid) { mailingList in
134                NavigationLink(value: MoreRoute.mailingList(mailingList)) {
135                    VStack(alignment: .leading, spacing: 4) {
136                        Text(mailingList.name)
137                            .font(.subheadline.weight(.medium))
138                        Text(mailingList.owner.canonicalName)
139                            .font(.caption)
140                            .foregroundStyle(.secondary)
141                    }
142                    .padding(.vertical, 2)
143                }
144            }
145            .themedRow()
146        }
147        .themedList()
148        .listStyle(.plain)
149        .searchable(
150            text: $vm.searchText,
151            placement: .navigationBarDrawer(displayMode: .always),
152            prompt: "Search lists"
153        )
154        .overlay {
155            if viewModel.isLoading, viewModel.mailingLists.isEmpty {
156                SRHTLoadingStateView(message: "Loading mailing lists…")
157            } else if let error = viewModel.error, viewModel.mailingLists.isEmpty {
158                SRHTErrorStateView(
159                    title: "Couldn't Load Mailing Lists",
160                    message: error,
161                    retryAction: { await viewModel.loadMailingLists() }
162                )
163            } else if !viewModel.mailingLists.isEmpty, viewModel.filteredMailingLists.isEmpty {
164                ContentUnavailableView.search(text: viewModel.searchText)
165            } else if viewModel.mailingLists.isEmpty {
166                ContentUnavailableView(
167                    "No Mailing Lists",
168                    systemImage: "list.bullet.rectangle",
169                    description: Text("Your subscribed mailing lists will appear here.")
170                )
171            }
172        }
173        .srhtErrorBanner(error: $vm.error)
174        .refreshable {
175            await viewModel.loadMailingLists()
176        }
177    }
178}