krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.7.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}