krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.3.0: Hutch/Views/Inbox/InboxViewModel.swift · raw
1import Foundation
2import os
3
4private let inboxListLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxList")
5
6private struct InboxSubscriptionsResponse: Decodable, Sendable {
7 let subscriptions: InboxSubscriptionPage
8}
9
10private struct InboxSubscriptionPage: Decodable, Sendable {
11 let results: [InboxActivitySubscription]
12 let cursor: String?
13}
14
15private struct InboxActivitySubscription: Decodable, Sendable {
16 let id: Int
17 let created: Date
18 let list: InboxMailingListReference?
19
20 enum CodingKeys: String, CodingKey {
21 case id
22 case created
23 case list
24 }
25}
26
27private struct InboxListThreadsResponse: Decodable, Sendable {
28 let list: InboxMailingListThreads
29}
30
31private struct InboxMailingListThreads: Decodable, Sendable {
32 let threads: InboxThreadPage
33}
34
35private struct InboxThreadPage: Decodable, Sendable {
36 let results: [InboxThreadPayload]
37 let cursor: String?
38}
39
40private struct InboxThreadPayload: Decodable, Sendable {
41 let created: Date
42 let updated: Date
43 let subject: String
44 let replies: Int
45 let sender: Entity
46 let root: InboxEmailPreview
47}
48
49private struct InboxEmailPreview: Decodable, Sendable {
50 let id: Int
51 let subject: String
52 let date: Date?
53 let received: Date
54 let messageID: String
55 let body: String
56 let patch: InboxPatchPreview?
57}
58
59@Observable
60@MainActor
61final class InboxViewModel {
62 private(set) var threads: [InboxThreadSummary] = []
63 private(set) var isLoading = false
64 var error: String?
65 var searchText = ""
66
67 private let client: SRHTClient
68 private let listThreadFetchLimit = 10
69 private let listFetchConcurrencyLimit = 4
70
71 private static let subscriptionsQuery = """
72 query inboxSubscriptions($cursor: Cursor) {
73 subscriptions(cursor: $cursor) {
74 results {
75 ... on MailingListSubscription {
76 id
77 created
78 list {
79 id
80 rid
81 name
82 owner { canonicalName }
83 }
84 }
85 }
86 cursor
87 }
88 }
89 """
90
91 private static let listThreadsQuery = """
92 query inboxListThreads($rid: ID!, $cursor: Cursor) {
93 list(rid: $rid) {
94 threads(cursor: $cursor) {
95 results {
96 created
97 updated
98 subject
99 replies
100 sender { canonicalName }
101 root {
102 id
103 subject
104 date
105 received
106 messageID
107 body
108 patch { subject }
109 }
110 }
111 cursor
112 }
113 }
114 }
115 """
116
117 init(client: SRHTClient) {
118 self.client = client
119 }
120
121 func loadThreads() async {
122 guard !isLoading else { return }
123 isLoading = true
124 error = nil
125 defer { isLoading = false }
126
127 do {
128 let subscriptions = try await fetchSubscriptions()
129 let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
130 let fetchedThreads = try await fetchThreads(for: mailingLists)
131 threads = fetchedThreads
132 .filter(\.isUnread)
133 .sorted { lhs, rhs in
134 if lhs.lastActivityAt == rhs.lastActivityAt {
135 return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
136 }
137 return lhs.lastActivityAt > rhs.lastActivityAt
138 }
139 } catch {
140 inboxListLogger.error("Inbox request failed")
141 self.error = "Failed to load inbox"
142 }
143 }
144
145 func markThreadRead(_ thread: InboxThreadSummary) {
146 let viewedAt = max(Date(), thread.lastActivityAt)
147 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
148 threads.removeAll { $0.id == thread.id }
149 }
150
151 func markThreadUnread(_ thread: InboxThreadSummary) {
152 InboxReadStateStore.markUnread(for: thread.id)
153 updateThread(thread, isUnread: true)
154 }
155
156 func toggleThreadReadState(_ thread: InboxThreadSummary) {
157 if thread.isUnread {
158 markThreadRead(thread)
159 } else {
160 markThreadUnread(thread)
161 }
162 }
163
164 func thread(withID id: InboxThreadSummary.ID) -> InboxThreadSummary? {
165 threads.first(where: { $0.id == id })
166 }
167
168 var filteredThreads: [InboxThreadSummary] {
169 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
170 guard !q.isEmpty else { return threads }
171 return threads.filter {
172 $0.displaySubject.lowercased().contains(q) ||
173 $0.listName.lowercased().contains(q) ||
174 $0.latestSender.canonicalName.lowercased().contains(q)
175 }
176 }
177
178 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
179 var subscriptions: [InboxActivitySubscription] = []
180 var cursor: String?
181
182 while true {
183 var variables: [String: any Sendable] = [:]
184 if let cursor {
185 variables["cursor"] = cursor
186 }
187
188 let response = try await client.execute(
189 service: .lists,
190 query: Self.subscriptionsQuery,
191 variables: variables.isEmpty ? nil : variables,
192 responseType: InboxSubscriptionsResponse.self
193 )
194
195 subscriptions.append(contentsOf: response.subscriptions.results)
196 guard let nextCursor = response.subscriptions.cursor else {
197 break
198 }
199 cursor = nextCursor
200 }
201
202 return subscriptions
203 }
204
205 private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] {
206 guard !mailingLists.isEmpty else { return [] }
207
208 var summaries: [InboxThreadSummary] = []
209 var startIndex = mailingLists.startIndex
210 var failureMessages: [String] = []
211
212 while startIndex < mailingLists.endIndex {
213 let endIndex = mailingLists.index(
214 startIndex,
215 offsetBy: listFetchConcurrencyLimit,
216 limitedBy: mailingLists.endIndex
217 ) ?? mailingLists.endIndex
218 let batch = Array(mailingLists[startIndex..<endIndex])
219
220 let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in
221 for mailingList in batch {
222 group.addTask {
223 do {
224 return (try await self.fetchThreads(for: mailingList), nil)
225 } catch {
226 return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
227 }
228 }
229 }
230
231 var batchSummaries: [InboxThreadSummary] = []
232 var batchFailures: [String] = []
233 for await result in group {
234 batchSummaries.append(contentsOf: result.0)
235 if let failure = result.1 {
236 batchFailures.append(failure)
237 }
238 }
239 return (batchSummaries, batchFailures)
240 }
241
242 summaries.append(contentsOf: batchResult.0)
243 failureMessages.append(contentsOf: batchResult.1)
244 for failure in batchResult.1 {
245 inboxListLogger.error("Inbox thread list request failed: \(failure, privacy: .private)")
246 }
247 startIndex = endIndex
248 }
249
250 if summaries.isEmpty, let firstFailure = failureMessages.first {
251 throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)])
252 }
253
254 return deduplicateThreads(summaries)
255 }
256
257 private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] {
258 let response = try await client.execute(
259 service: .lists,
260 query: Self.listThreadsQuery,
261 variables: ["rid": mailingList.rid],
262 responseType: InboxListThreadsResponse.self
263 )
264
265 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
266 let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())"
267 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
268 return InboxThreadSummary(
269 rootEmailID: thread.root.id,
270 rootMessageID: thread.root.messageID,
271 threadRootEmailIDs: [thread.root.id],
272 threadRootMessageIDs: [thread.root.messageID],
273 listID: mailingList.id,
274 listRID: mailingList.rid,
275 listName: mailingList.name,
276 listOwner: mailingList.owner,
277 subject: thread.subject,
278 latestSender: thread.sender,
279 lastActivityAt: thread.updated,
280 messageCount: thread.replies + 1,
281 repo: Self.deriveRepositoryName(from: mailingList.name),
282 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
283 isUnread: isUnread
284 )
285 }
286 }
287
288 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
289 var grouped: [String: InboxThreadSummary] = [:]
290
291 for thread in threads {
292 guard let existing = grouped[thread.threadGroupingKey] else {
293 grouped[thread.threadGroupingKey] = thread
294 continue
295 }
296
297 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
298 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
299 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
300 let mergedMessageCount = max(
301 existing.messageCount ?? existing.threadRootMessageIDs.count,
302 thread.messageCount ?? thread.threadRootMessageIDs.count,
303 mergedRootMessageIDs.count
304 )
305
306 grouped[thread.threadGroupingKey] = InboxThreadSummary(
307 rootEmailID: latest.rootEmailID,
308 rootMessageID: latest.rootMessageID,
309 threadRootEmailIDs: mergedRootEmailIDs,
310 threadRootMessageIDs: mergedRootMessageIDs,
311 listID: latest.listID,
312 listRID: latest.listRID,
313 listName: latest.listName,
314 listOwner: latest.listOwner,
315 subject: latest.subject,
316 latestSender: latest.latestSender,
317 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
318 messageCount: mergedMessageCount,
319 repo: latest.repo ?? existing.repo,
320 containsPatch: latest.containsPatch || existing.containsPatch,
321 isUnread: latest.isUnread || existing.isUnread
322 )
323 }
324
325 return grouped.values.sorted { lhs, rhs in
326 if lhs.lastActivityAt == rhs.lastActivityAt {
327 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
328 }
329 return lhs.lastActivityAt > rhs.lastActivityAt
330 }
331 }
332
333 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
334 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
335 let current = threads[index]
336 if !isUnread {
337 threads.remove(at: index)
338 return
339 }
340 threads[index] = InboxThreadSummary(
341 rootEmailID: current.rootEmailID,
342 rootMessageID: current.rootMessageID,
343 threadRootEmailIDs: current.threadRootEmailIDs,
344 threadRootMessageIDs: current.threadRootMessageIDs,
345 listID: current.listID,
346 listRID: current.listRID,
347 listName: current.listName,
348 listOwner: current.listOwner,
349 subject: current.subject,
350 latestSender: current.latestSender,
351 lastActivityAt: current.lastActivityAt,
352 messageCount: current.messageCount,
353 repo: current.repo,
354 containsPatch: current.containsPatch,
355 isUnread: isUnread
356 )
357 }
358
359 private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
360 var seen = Set<String>()
361 return mailingLists.filter { mailingList in
362 seen.insert(mailingList.rid).inserted
363 }
364 }
365
366 nonisolated static func deriveRepositoryName(from listName: String) -> String? {
367 let separators = ["-devel", "-patches", "-dev", ".patches"]
368 for separator in separators where listName.hasSuffix(separator) {
369 return String(listName.dropLast(separator.count))
370 }
371 return nil
372 }
373}