krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.5.2: 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("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 }
146 .listStyle(.plain)
147 .searchable(
148 text: $vm.searchText,
149 placement: .navigationBarDrawer(displayMode: .always),
150 prompt: "Search lists"
151 )
152 .overlay {
153 if viewModel.isLoading, viewModel.mailingLists.isEmpty {
154 SRHTLoadingStateView(message: "Loading mailing lists…")
155 } else if let error = viewModel.error, viewModel.mailingLists.isEmpty {
156 SRHTErrorStateView(
157 title: "Couldn't Load Mailing Lists",
158 message: error,
159 retryAction: { await viewModel.loadMailingLists() }
160 )
161 } else if !viewModel.mailingLists.isEmpty, viewModel.filteredMailingLists.isEmpty {
162 ContentUnavailableView.search(text: viewModel.searchText)
163 } else if viewModel.mailingLists.isEmpty {
164 ContentUnavailableView(
165 "No Mailing Lists",
166 systemImage: "list.bullet.rectangle",
167 description: Text("Your subscribed mailing lists will appear here.")
168 )
169 }
170 }
171 .srhtErrorBanner(error: $vm.error)
172 .refreshable {
173 await viewModel.loadMailingLists()
174 }
175 }
176}