krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.6.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 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count)
140 } catch {
141 inboxListLogger.error("Inbox request failed")
142 self.error = "Failed to load inbox"
143 }
144 }
145
146 func markThreadRead(_ thread: InboxThreadSummary) {
147 let viewedAt = max(Date(), thread.lastActivityAt)
148 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
149 threads.removeAll { $0.id == thread.id }
150 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
151 }
152
153 func markThreadUnread(_ thread: InboxThreadSummary) {
154 InboxReadStateStore.markUnread(for: thread.id)
155 updateThread(thread, isUnread: true)
156 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
157 }
158
159 func toggleThreadReadState(_ thread: InboxThreadSummary) {
160 if thread.isUnread {
161 markThreadRead(thread)
162 } else {
163 markThreadUnread(thread)
164 }
165 }
166
167 func thread(withID id: InboxThreadSummary.ID) -> InboxThreadSummary? {
168 threads.first(where: { $0.id == id })
169 }
170
171 var filteredThreads: [InboxThreadSummary] {
172 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
173 guard !q.isEmpty else { return threads }
174 return threads.filter {
175 $0.displaySubject.lowercased().contains(q) ||
176 $0.listName.lowercased().contains(q) ||
177 $0.latestSender.canonicalName.lowercased().contains(q)
178 }
179 }
180
181 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
182 var subscriptions: [InboxActivitySubscription] = []
183 var cursor: String?
184
185 while true {
186 var variables: [String: any Sendable] = [:]
187 if let cursor {
188 variables["cursor"] = cursor
189 }
190
191 let response = try await client.execute(
192 service: .lists,
193 query: Self.subscriptionsQuery,
194 variables: variables.isEmpty ? nil : variables,
195 responseType: InboxSubscriptionsResponse.self
196 )
197
198 subscriptions.append(contentsOf: response.subscriptions.results)
199 guard let nextCursor = response.subscriptions.cursor else {
200 break
201 }
202 cursor = nextCursor
203 }
204
205 return subscriptions
206 }
207
208 private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] {
209 guard !mailingLists.isEmpty else { return [] }
210
211 var summaries: [InboxThreadSummary] = []
212 var startIndex = mailingLists.startIndex
213 var failureMessages: [String] = []
214
215 while startIndex < mailingLists.endIndex {
216 let endIndex = mailingLists.index(
217 startIndex,
218 offsetBy: listFetchConcurrencyLimit,
219 limitedBy: mailingLists.endIndex
220 ) ?? mailingLists.endIndex
221 let batch = Array(mailingLists[startIndex..<endIndex])
222
223 let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in
224 for mailingList in batch {
225 group.addTask {
226 do {
227 return (try await self.fetchThreads(for: mailingList), nil)
228 } catch {
229 return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
230 }
231 }
232 }
233
234 var batchSummaries: [InboxThreadSummary] = []
235 var batchFailures: [String] = []
236 for await result in group {
237 batchSummaries.append(contentsOf: result.0)
238 if let failure = result.1 {
239 batchFailures.append(failure)
240 }
241 }
242 return (batchSummaries, batchFailures)
243 }
244
245 summaries.append(contentsOf: batchResult.0)
246 failureMessages.append(contentsOf: batchResult.1)
247 for failure in batchResult.1 {
248 inboxListLogger.error("Inbox thread list request failed: \(failure, privacy: .private)")
249 }
250 startIndex = endIndex
251 }
252
253 if summaries.isEmpty, let firstFailure = failureMessages.first {
254 throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)])
255 }
256
257 return deduplicateThreads(summaries)
258 }
259
260 private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] {
261 let response = try await client.execute(
262 service: .lists,
263 query: Self.listThreadsQuery,
264 variables: ["rid": mailingList.rid],
265 responseType: InboxListThreadsResponse.self
266 )
267
268 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
269 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())"
270 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
271 return InboxThreadSummary(
272 rootEmailID: thread.root.id,
273 rootMessageID: thread.root.messageID,
274 threadRootEmailIDs: [thread.root.id],
275 threadRootMessageIDs: [thread.root.messageID],
276 listID: mailingList.id,
277 listRID: mailingList.rid,
278 listName: mailingList.name,
279 listOwner: mailingList.owner,
280 subject: thread.subject,
281 latestSender: thread.sender,
282 lastActivityAt: thread.updated,
283 messageCount: thread.replies + 1,
284 repo: Self.deriveRepositoryName(from: mailingList.name),
285 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
286 isUnread: isUnread
287 )
288 }
289 }
290
291 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
292 var grouped: [String: InboxThreadSummary] = [:]
293
294 for thread in threads {
295 guard let existing = grouped[thread.threadGroupingKey] else {
296 grouped[thread.threadGroupingKey] = thread
297 continue
298 }
299
300 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
301 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
302 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
303 let mergedMessageCount = max(
304 existing.messageCount ?? existing.threadRootMessageIDs.count,
305 thread.messageCount ?? thread.threadRootMessageIDs.count,
306 mergedRootMessageIDs.count
307 )
308
309 grouped[thread.threadGroupingKey] = InboxThreadSummary(
310 rootEmailID: latest.rootEmailID,
311 rootMessageID: latest.rootMessageID,
312 threadRootEmailIDs: mergedRootEmailIDs,
313 threadRootMessageIDs: mergedRootMessageIDs,
314 listID: latest.listID,
315 listRID: latest.listRID,
316 listName: latest.listName,
317 listOwner: latest.listOwner,
318 subject: latest.subject,
319 latestSender: latest.latestSender,
320 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
321 messageCount: mergedMessageCount,
322 repo: latest.repo ?? existing.repo,
323 containsPatch: latest.containsPatch || existing.containsPatch,
324 isUnread: latest.isUnread || existing.isUnread
325 )
326 }
327
328 return grouped.values.sorted { lhs, rhs in
329 if lhs.lastActivityAt == rhs.lastActivityAt {
330 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
331 }
332 return lhs.lastActivityAt > rhs.lastActivityAt
333 }
334 }
335
336 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
337 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
338 let current = threads[index]
339 if !isUnread {
340 threads.remove(at: index)
341 return
342 }
343 threads[index] = InboxThreadSummary(
344 rootEmailID: current.rootEmailID,
345 rootMessageID: current.rootMessageID,
346 threadRootEmailIDs: current.threadRootEmailIDs,
347 threadRootMessageIDs: current.threadRootMessageIDs,
348 listID: current.listID,
349 listRID: current.listRID,
350 listName: current.listName,
351 listOwner: current.listOwner,
352 subject: current.subject,
353 latestSender: current.latestSender,
354 lastActivityAt: current.lastActivityAt,
355 messageCount: current.messageCount,
356 repo: current.repo,
357 containsPatch: current.containsPatch,
358 isUnread: isUnread
359 )
360 }
361
362 private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
363 var seen = Set<String>()
364 return mailingLists.filter { mailingList in
365 seen.insert(mailingList.rid).inserted
366 }
367 }
368
369 nonisolated static func deriveRepositoryName(from listName: String) -> String? {
370 let separators = ["-devel", "-patches", "-dev", ".patches"]
371 for separator in separators where listName.hasSuffix(separator) {
372 return String(listName.dropLast(separator.count))
373 }
374 return nil
375 }
376}