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