krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: 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
66 private let client: SRHTClient
67 private let listThreadFetchLimit = 10
68 private let listFetchConcurrencyLimit = 4
69
70 private static let subscriptionsQuery = """
71 query inboxSubscriptions($cursor: Cursor) {
72 subscriptions(cursor: $cursor) {
73 results {
74 ... on MailingListSubscription {
75 id
76 created
77 list {
78 id
79 rid
80 name
81 owner { canonicalName }
82 }
83 }
84 }
85 cursor
86 }
87 }
88 """
89
90 private static let listThreadsQuery = """
91 query inboxListThreads($rid: ID!, $cursor: Cursor) {
92 list(rid: $rid) {
93 threads(cursor: $cursor) {
94 results {
95 created
96 updated
97 subject
98 replies
99 sender { canonicalName }
100 root {
101 id
102 subject
103 date
104 received
105 messageID
106 body
107 patch { subject }
108 }
109 }
110 cursor
111 }
112 }
113 }
114 """
115
116 init(client: SRHTClient) {
117 self.client = client
118 }
119
120 func loadThreads() async {
121 guard !isLoading else { return }
122 isLoading = true
123 error = nil
124 defer { isLoading = false }
125
126 do {
127 let subscriptions = try await fetchSubscriptions()
128 let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
129 let fetchedThreads = try await fetchThreads(for: mailingLists)
130 threads = fetchedThreads
131 .filter(\.isUnread)
132 .sorted { lhs, rhs in
133 if lhs.lastActivityAt == rhs.lastActivityAt {
134 return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
135 }
136 return lhs.lastActivityAt > rhs.lastActivityAt
137 }
138 } catch {
139 inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)")
140 self.error = "Failed to load inbox"
141 }
142 }
143
144 func markThreadRead(_ thread: InboxThreadSummary) {
145 let viewedAt = max(Date(), thread.lastActivityAt)
146 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
147 inboxListLogger.debug(
148 "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
149 )
150 threads.removeAll { $0.id == thread.id }
151 }
152
153 func markThreadUnread(_ thread: InboxThreadSummary) {
154 InboxReadStateStore.markUnread(for: thread.id)
155 inboxListLogger.debug(
156 "Inbox mark unread: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=nil"
157 )
158 updateThread(thread, isUnread: true)
159 }
160
161 func toggleThreadReadState(_ thread: InboxThreadSummary) {
162 if thread.isUnread {
163 markThreadRead(thread)
164 } else {
165 markThreadUnread(thread)
166 }
167 }
168
169 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
170 var subscriptions: [InboxActivitySubscription] = []
171 var cursor: String?
172
173 while true {
174 var variables: [String: any Sendable] = [:]
175 if let cursor {
176 variables["cursor"] = cursor
177 }
178
179 let response = try await client.execute(
180 service: .lists,
181 query: Self.subscriptionsQuery,
182 variables: variables.isEmpty ? nil : variables,
183 responseType: InboxSubscriptionsResponse.self
184 )
185
186 subscriptions.append(contentsOf: response.subscriptions.results)
187 guard let nextCursor = response.subscriptions.cursor else {
188 break
189 }
190 cursor = nextCursor
191 }
192
193 return subscriptions
194 }
195
196 private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] {
197 guard !mailingLists.isEmpty else { return [] }
198
199 var summaries: [InboxThreadSummary] = []
200 var startIndex = mailingLists.startIndex
201 var failureMessages: [String] = []
202
203 while startIndex < mailingLists.endIndex {
204 let endIndex = mailingLists.index(
205 startIndex,
206 offsetBy: listFetchConcurrencyLimit,
207 limitedBy: mailingLists.endIndex
208 ) ?? mailingLists.endIndex
209 let batch = Array(mailingLists[startIndex..<endIndex])
210
211 let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in
212 for mailingList in batch {
213 group.addTask {
214 do {
215 return (try await self.fetchThreads(for: mailingList), nil)
216 } catch {
217 return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
218 }
219 }
220 }
221
222 var batchSummaries: [InboxThreadSummary] = []
223 var batchFailures: [String] = []
224 for await result in group {
225 batchSummaries.append(contentsOf: result.0)
226 if let failure = result.1 {
227 batchFailures.append(failure)
228 }
229 }
230 return (batchSummaries, batchFailures)
231 }
232
233 summaries.append(contentsOf: batchResult.0)
234 failureMessages.append(contentsOf: batchResult.1)
235 for failure in batchResult.1 {
236 inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)")
237 }
238 startIndex = endIndex
239 }
240
241 if summaries.isEmpty, let firstFailure = failureMessages.first {
242 throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)])
243 }
244
245 return deduplicateThreads(summaries)
246 }
247
248 private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] {
249 let response = try await client.execute(
250 service: .lists,
251 query: Self.listThreadsQuery,
252 variables: ["rid": mailingList.rid],
253 responseType: InboxListThreadsResponse.self
254 )
255
256 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
257 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())"
258 let lastViewedAt = InboxReadStateStore.lastViewedAt(for: groupingKey)
259 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
260 inboxListLogger.debug(
261 "Inbox thread grouping candidate: listRID=\(mailingList.rid, privacy: .public) rootMessageID=\(thread.root.messageID, privacy: .public) rootEmailID=\(thread.root.id, privacy: .public) groupingKey=\(groupingKey, privacy: .public)"
262 )
263 inboxListLogger.debug(
264 "Inbox unread state: key=\(groupingKey, privacy: .public) latestActivityAt=\(thread.updated.ISO8601Format(), privacy: .public) lastViewedAt=\(lastViewedAt?.ISO8601Format() ?? "nil", privacy: .public) isUnread=\(isUnread, privacy: .public)"
265 )
266 return InboxThreadSummary(
267 rootEmailID: thread.root.id,
268 rootMessageID: thread.root.messageID,
269 threadRootEmailIDs: [thread.root.id],
270 threadRootMessageIDs: [thread.root.messageID],
271 listID: mailingList.id,
272 listRID: mailingList.rid,
273 listName: mailingList.name,
274 listOwner: mailingList.owner,
275 subject: thread.subject,
276 latestSender: thread.sender,
277 lastActivityAt: thread.updated,
278 messageCount: thread.replies + 1,
279 repo: Self.deriveRepositoryName(from: mailingList.name),
280 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
281 isUnread: isUnread
282 )
283 }
284 }
285
286 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
287 var grouped: [String: InboxThreadSummary] = [:]
288
289 for thread in threads {
290 guard let existing = grouped[thread.threadGroupingKey] else {
291 grouped[thread.threadGroupingKey] = thread
292 continue
293 }
294
295 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
296 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
297 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
298 let mergedMessageCount = max(
299 existing.messageCount ?? existing.threadRootMessageIDs.count,
300 thread.messageCount ?? thread.threadRootMessageIDs.count,
301 mergedRootMessageIDs.count
302 )
303
304 grouped[thread.threadGroupingKey] = InboxThreadSummary(
305 rootEmailID: latest.rootEmailID,
306 rootMessageID: latest.rootMessageID,
307 threadRootEmailIDs: mergedRootEmailIDs,
308 threadRootMessageIDs: mergedRootMessageIDs,
309 listID: latest.listID,
310 listRID: latest.listRID,
311 listName: latest.listName,
312 listOwner: latest.listOwner,
313 subject: latest.subject,
314 latestSender: latest.latestSender,
315 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
316 messageCount: mergedMessageCount,
317 repo: latest.repo ?? existing.repo,
318 containsPatch: latest.containsPatch || existing.containsPatch,
319 isUnread: latest.isUnread || existing.isUnread
320 )
321 }
322
323 return grouped.values.sorted { lhs, rhs in
324 if lhs.lastActivityAt == rhs.lastActivityAt {
325 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
326 }
327 return lhs.lastActivityAt > rhs.lastActivityAt
328 }
329 }
330
331 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
332 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
333 let current = threads[index]
334 if !isUnread {
335 threads.remove(at: index)
336 return
337 }
338 threads[index] = InboxThreadSummary(
339 rootEmailID: current.rootEmailID,
340 rootMessageID: current.rootMessageID,
341 threadRootEmailIDs: current.threadRootEmailIDs,
342 threadRootMessageIDs: current.threadRootMessageIDs,
343 listID: current.listID,
344 listRID: current.listRID,
345 listName: current.listName,
346 listOwner: current.listOwner,
347 subject: current.subject,
348 latestSender: current.latestSender,
349 lastActivityAt: current.lastActivityAt,
350 messageCount: current.messageCount,
351 repo: current.repo,
352 containsPatch: current.containsPatch,
353 isUnread: isUnread
354 )
355 }
356
357 private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
358 var seen = Set<String>()
359 return mailingLists.filter { mailingList in
360 seen.insert(mailingList.rid).inserted
361 }
362 }
363
364 nonisolated static func deriveRepositoryName(from listName: String) -> String? {
365 let separators = ["-devel", "-patches", "-dev", ".patches"]
366 for separator in separators where listName.hasSuffix(separator) {
367 return String(listName.dropLast(separator.count))
368 }
369 return nil
370 }
371}