krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.7.1: Hutch/Views/Inbox/ThreadViewModel.swift · raw
1import Foundation
2import os
3
4private let inboxLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Inbox")
5
6private struct InboxThreadDetailResponse: Decodable, Sendable {
7 let list: InboxThreadDetailList?
8}
9
10private struct InboxThreadDetailList: Decodable, Sendable {
11 let threads: InboxThreadPayloadPage?
12}
13
14private struct InboxThreadLookupResponse: Decodable, Sendable {
15 let list: InboxThreadLookupList?
16}
17
18private struct InboxThreadLookupList: Decodable, Sendable {
19 let message: InboxThreadLookupMessage?
20}
21
22private struct InboxThreadLookupMessage: Decodable, Sendable {
23 let thread: InboxThreadPayloadDetail?
24}
25
26private struct InboxThreadPayloadDetail: Decodable, Sendable {
27 let subject: String?
28 let updated: Date?
29 let replies: Int?
30 let sender: Entity?
31 let list: InboxMailingListReference?
32 let root: InboxThreadMessagePayload?
33 let descendants: InboxThreadMessagesPage?
34}
35
36private struct InboxThreadPayloadPage: Decodable, Sendable {
37 let results: [InboxThreadPayloadDetail]
38 let cursor: String?
39}
40
41private struct InboxThreadMessagesPage: Decodable, Sendable {
42 let results: [InboxThreadMessagePayload]?
43 let cursor: String?
44}
45
46private struct InboxThreadMessagePayload: Decodable, Sendable {
47 let id: Int?
48 let sender: Entity?
49 let received: Date?
50 let date: Date?
51 let subject: String?
52 let messageID: String?
53 let body: String?
54 let rawMessage: URL?
55 let patch: InboxPatchPreview?
56}
57
58@Observable
59@MainActor
60final class ThreadViewModel {
61 private(set) var thread: InboxThreadDetail?
62 private(set) var isLoading = false
63 var error: String?
64 var partialWarning: String?
65 var composeDraft: MailComposeDraft?
66
67 private let summary: InboxThreadSummary
68 private let client: SRHTClient
69
70 private static let threadDetailQuery = """
71 query inboxThreadDetail($rid: ID!, $cursor: Cursor, $descCursor: Cursor) {
72 list(rid: $rid) {
73 threads(cursor: $cursor) {
74 results {
75 subject
76 updated
77 replies
78 sender { canonicalName }
79 list {
80 id
81 rid
82 name
83 owner { canonicalName }
84 }
85 root {
86 id
87 sender { canonicalName }
88 received
89 date
90 subject
91 messageID
92 body
93 rawMessage
94 patch { subject }
95 }
96 descendants(cursor: $descCursor) {
97 results {
98 id
99 sender { canonicalName }
100 received
101 date
102 subject
103 messageID
104 body
105 rawMessage
106 patch { subject }
107 }
108 cursor
109 }
110 }
111 cursor
112 }
113 }
114 }
115 """
116
117 private static let threadByMessageIDQuery = """
118 query inboxThreadByMessageID($rid: ID!, $messageID: String!, $descCursor: Cursor) {
119 list(rid: $rid) {
120 message(messageID: $messageID) {
121 thread {
122 subject
123 updated
124 replies
125 sender { canonicalName }
126 list {
127 id
128 rid
129 name
130 owner { canonicalName }
131 }
132 root {
133 id
134 sender { canonicalName }
135 received
136 date
137 subject
138 messageID
139 body
140 rawMessage
141 patch { subject }
142 }
143 descendants(cursor: $descCursor) {
144 results {
145 id
146 sender { canonicalName }
147 received
148 date
149 subject
150 messageID
151 body
152 rawMessage
153 patch { subject }
154 }
155 cursor
156 }
157 }
158 }
159 }
160 }
161 """
162
163 init(summary: InboxThreadSummary, client: SRHTClient) {
164 self.summary = summary
165 self.client = client
166 }
167
168 func loadThread() async {
169 guard !isLoading else { return }
170 isLoading = true
171 error = nil
172 partialWarning = nil
173 defer { isLoading = false }
174
175 do {
176 let threadPayloads = try await fetchThreadPayloads()
177
178 guard !threadPayloads.isEmpty else {
179 throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
180 }
181
182 let listReference = threadPayloads.lazy.compactMap(\.list).first ?? InboxMailingListReference(
183 id: summary.listID,
184 rid: summary.listRID,
185 name: summary.listName,
186 owner: summary.listOwner
187 )
188 var messagesByID: [Int: InboxMessage] = [:]
189
190 var hadPartialReplyFailure = false
191
192 for payload in threadPayloads {
193 guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
194 continue
195 }
196 messagesByID[rootMessage.id] = rootMessage
197
198 do {
199 let descendantMessages = try await fetchAllDescendantMessages(
200 initialPayload: payload,
201 candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
202 )
203 for message in descendantMessages {
204 messagesByID[message.id] = message
205 }
206 } catch {
207 hadPartialReplyFailure = true
208 inboxLogger.error("Inbox thread descendants failed")
209 }
210 }
211
212 let messages = messagesByID.values.sorted { $0.date < $1.date }
213 guard !messages.isEmpty else {
214 throw SRHTError.graphQLErrors([GraphQLError(message: "Thread root message is unavailable.", locations: nil)])
215 }
216
217 let latestPayload = threadPayloads.max(by: { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) }) ?? threadPayloads[0]
218 thread = InboxThreadDetail(
219 id: summary.id,
220 rootEmailID: summary.rootEmailID,
221 rootMessageID: summary.rootMessageID,
222 subject: latestPayload.subject ?? summary.subject,
223 author: latestPayload.sender ?? summary.latestSender,
224 lastActivityAt: latestPayload.updated ?? summary.lastActivityAt,
225 mailto: nil,
226 listID: listReference.id,
227 listRID: listReference.rid,
228 listName: listReference.name,
229 listOwner: listReference.owner,
230 messageCount: max(messages.count, summary.messageCount ?? 0),
231 messages: messages
232 )
233 if hadPartialReplyFailure {
234 partialWarning = "Some replies could not be loaded."
235 }
236 } catch {
237 if thread == nil {
238 self.error = "Failed to load thread"
239 } else {
240 self.error = error.userFacingMessage
241 }
242 inboxLogger.error("Inbox thread detail failed")
243 }
244 }
245
246 private func fetchThreadPayloads() async throws -> [InboxThreadPayloadDetail] {
247 var payloads: [InboxThreadPayloadDetail] = []
248 var seenRoots = Set<String>()
249
250 for rootMessageID in summary.threadRootMessageIDs {
251 guard !seenRoots.contains(rootMessageID) else { continue }
252 seenRoots.insert(rootMessageID)
253 if let payload = try await fetchThreadPayload(rootMessageID: rootMessageID) {
254 payloads.append(payload)
255 }
256 }
257
258 if payloads.isEmpty, let fallback = try await fetchThreadPayload(rootMessageID: summary.rootMessageID) {
259 payloads.append(fallback)
260 }
261
262 return payloads
263 }
264
265 private func fetchThreadPayload(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
266 if let messageMatchedThread = try await fetchThreadByMessageID(rootMessageID: rootMessageID) {
267 return messageMatchedThread
268 }
269 return try await scanThreadPages(targetRootMessageID: rootMessageID)
270 }
271
272 private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
273 let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID)
274 var lastLookupError: Error?
275
276 for messageID in candidateMessageIDs {
277 do {
278 let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
279 client: client,
280 query: Self.threadByMessageIDQuery,
281 variables: [
282 "rid": self.summary.listRID,
283 "messageID": messageID,
284 "descCursor": nil as String?
285 ]
286 )
287
288 if let thread = response.list?.message?.thread {
289 return thread
290 }
291 } catch let error as SRHTError {
292 switch error {
293 case .graphQLErrors(let errors):
294 if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) {
295 lastLookupError = error
296 continue
297 }
298 throw error
299 default:
300 throw error
301 }
302 }
303 }
304
305 _ = lastLookupError
306 return nil
307 }
308
309 private func scanThreadPages(targetRootMessageID: String) async throws -> InboxThreadPayloadDetail? {
310 var threadCursor: String?
311
312 while true {
313 var variables: [String: any Sendable] = ["rid": summary.listRID]
314 if let threadCursor {
315 variables["cursor"] = threadCursor
316 }
317
318 let response: InboxThreadDetailResponse
319 do {
320 response = try await Self.executeGraphQLRequest(
321 client: client,
322 query: Self.threadDetailQuery,
323 variables: {
324 var variables = variables
325 variables["descCursor"] = nil as String?
326 return variables
327 }()
328 )
329 } catch {
330 if Self.isRecoverableNoRows(error) {
331 inboxLogger.error("Inbox thread page scan missed a recoverable result")
332 return nil
333 }
334 throw error
335 }
336
337 guard let threadPage = response.list?.threads else {
338 return nil
339 }
340
341 if let matchedThread = threadPage.results.first(where: {
342 $0.root?.messageID == targetRootMessageID ||
343 $0.root?.id == summary.rootEmailID ||
344 $0.root?.subject == summary.subject
345 }) {
346 return matchedThread
347 }
348
349 guard let nextCursor = threadPage.cursor else {
350 return nil
351 }
352 threadCursor = nextCursor
353 }
354 }
355
356 private func fetchAllDescendantMessages(
357 initialPayload: InboxThreadPayloadDetail,
358 candidateMessageIDs: [String]
359 ) async throws -> [InboxMessage] {
360 var messagesByID: [Int: InboxMessage] = [:]
361
362 for payload in initialPayload.descendants?.results ?? [] {
363 if let message = Self.message(from: payload, fallbackID: nil) {
364 messagesByID[message.id] = message
365 }
366 }
367
368 var descendantCursor = initialPayload.descendants?.cursor
369 while let currentCursor = descendantCursor {
370 guard let page = try await fetchDescendantPage(
371 cursor: currentCursor,
372 candidateMessageIDs: candidateMessageIDs
373 ) else {
374 break
375 }
376
377 for payload in page.results ?? [] {
378 if let message = Self.message(from: payload, fallbackID: nil) {
379 messagesByID[message.id] = message
380 }
381 }
382 descendantCursor = page.cursor
383 }
384
385 return messagesByID.values.sorted { $0.date < $1.date }
386 }
387
388 private func fetchDescendantPage(
389 cursor: String,
390 candidateMessageIDs: [String]
391 ) async throws -> InboxThreadMessagesPage? {
392 for messageID in candidateMessageIDs {
393 let response: InboxThreadLookupResponse
394 do {
395 response = try await Self.executeGraphQLRequest(
396 client: client,
397 query: Self.threadByMessageIDQuery,
398 variables: [
399 "rid": summary.listRID,
400 "messageID": messageID,
401 "descCursor": cursor
402 ]
403 )
404 } catch {
405 if Self.isRecoverableNoRows(error) {
406 inboxLogger.error("Inbox descendant page missed a recoverable result")
407 continue
408 }
409 throw error
410 }
411
412 if let descendants = response.list?.message?.thread?.descendants {
413 return descendants
414 }
415 }
416
417 return nil
418 }
419
420 func prepareReply() {
421 guard let thread else {
422 error = "This thread is not ready to reply to yet."
423 return
424 }
425 composeDraft = MailComposeDraft(
426 recipients: [thread.replyRecipient],
427 ccRecipients: [],
428 subject: thread.replySubject,
429 body: ""
430 )
431 }
432
433 func dismissReply() {
434 composeDraft = nil
435 }
436
437 private static func message(from payload: InboxThreadMessagePayload?, fallbackID: Int?) -> InboxMessage? {
438 guard let payload else { return nil }
439 guard let id = payload.id ?? fallbackID,
440 let author = payload.sender,
441 let date = payload.date ?? payload.received,
442 let subject = payload.subject,
443 let body = payload.body else {
444 return nil
445 }
446
447 let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author)
448 let displayBody = sanitizedDisplayBody(from: body)
449 let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil)
450
451 return InboxMessage(
452 id: id,
453 author: author,
454 date: date,
455 subject: subject,
456 body: body,
457 senderDisplayName: normalizedIdentity.displayName,
458 senderEmailAddress: normalizedIdentity.emailAddress,
459 isPatch: payload.patch != nil,
460 contentBlocks: contentBlocks,
461 rawMessageURL: payload.rawMessage
462 )
463 }
464
465 nonisolated static func mailComposeDraft(from mailto: String) -> MailComposeDraft? {
466 guard let components = URLComponents(string: mailto),
467 components.scheme?.lowercased() == "mailto" else {
468 return nil
469 }
470
471 let recipients = components.path
472 .split(separator: ",")
473 .map { String($0) }
474 .filter { !$0.isEmpty }
475 let queryItems = components.queryItems ?? []
476 let ccRecipients = queryItems
477 .first(where: { $0.name.caseInsensitiveCompare("cc") == .orderedSame })?
478 .value?
479 .split(separator: ",")
480 .map(String.init) ?? []
481 let subject = queryItems
482 .first(where: { $0.name.caseInsensitiveCompare("subject") == .orderedSame })?
483 .value ?? ""
484 let body = queryItems
485 .first(where: { $0.name.caseInsensitiveCompare("body") == .orderedSame })?
486 .value ?? ""
487
488 return MailComposeDraft(
489 recipients: recipients,
490 ccRecipients: ccRecipients,
491 subject: subject,
492 body: body
493 )
494 }
495
496 private static func messageIDCandidates(from messageID: String) -> [String] {
497 let trimmedMessageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines)
498 guard !trimmedMessageID.isEmpty else { return [] }
499
500 if trimmedMessageID.hasPrefix("<"), trimmedMessageID.hasSuffix(">") {
501 return [trimmedMessageID, String(trimmedMessageID.dropFirst().dropLast())]
502 }
503
504 return [trimmedMessageID, "<\(trimmedMessageID)>"]
505 }
506
507 private static func normalizedSenderIdentity(from body: String, fallbackAuthor: Entity) -> (displayName: String, emailAddress: String?) {
508 guard let fromLine = leadingHeaderValue(named: "From", in: body) else {
509 return fallbackSenderIdentity(from: fallbackAuthor)
510 }
511
512 let trimmedFromLine = fromLine.trimmingCharacters(in: .whitespacesAndNewlines)
513 if let start = trimmedFromLine.lastIndex(of: "<"),
514 let end = trimmedFromLine.lastIndex(of: ">"),
515 start < end {
516 let email = String(trimmedFromLine[trimmedFromLine.index(after: start)..<end]).trimmingCharacters(in: .whitespaces)
517 let name = String(trimmedFromLine[..<start]).trimmingCharacters(in: .whitespacesAndNewlines)
518 if !name.isEmpty {
519 return (name, email.isEmpty ? nil : email)
520 }
521 return (email.isEmpty ? trimmedFromLine : email, email.isEmpty ? nil : email)
522 }
523
524 if trimmedFromLine.contains("@") {
525 return (trimmedFromLine, trimmedFromLine)
526 }
527
528 return (trimmedFromLine, nil)
529 }
530
531 private static func fallbackSenderIdentity(from author: Entity) -> (displayName: String, emailAddress: String?) {
532 let canonicalName = author.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
533 if canonicalName.contains("@") {
534 return (canonicalName, canonicalName)
535 }
536 if canonicalName.hasPrefix("~") {
537 return (String(canonicalName.dropFirst()), nil)
538 }
539 return (canonicalName, nil)
540 }
541
542 private static func sanitizedDisplayBody(from body: String) -> String {
543 let normalizedBody = normalizeLineEndings(in: body)
544 let lines = normalizedBody.components(separatedBy: "\n")
545 let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
546 var headerCount = 0
547 var blankLineIndex: Int?
548
549 for (index, line) in lines.prefix(12).enumerated() {
550 if line.isEmpty {
551 blankLineIndex = index
552 break
553 }
554 if headerPrefixes.contains(where: { line.hasPrefix($0) }) {
555 headerCount += 1
556 } else if headerCount > 0 {
557 break
558 }
559 }
560
561 guard headerCount >= 2, let blankLineIndex else {
562 return stripLeadingFromLineIfPresent(in: normalizedBody)
563 }
564
565 return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
566 }
567
568 nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
569 segmentMessageBody(body, isPatch: isPatch)
570 }
571
572 private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
573 guard isPatch else {
574 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
575 return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
576 }
577
578 let normalizedBody = normalizeLineEndings(in: body)
579 let lines = normalizedBody.components(separatedBy: "\n")
580 guard let diffStartIndex = actualDiffStartIndex(in: lines) else {
581 let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines)
582 return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
583 }
584
585 var blocks: [InboxMessageContentBlock] = []
586 let leadingPlainText = lines[..<diffStartIndex]
587 .joined(separator: "\n")
588 .trimmingCharacters(in: .whitespacesAndNewlines)
589 if !leadingPlainText.isEmpty {
590 blocks.append(.plainText(leadingPlainText))
591 }
592
593 let remainingLines = Array(lines[diffStartIndex...])
594 let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
595
596 let diffLines: ArraySlice<String>
597 let trailingPlainText: String
598 if let signatureIndex {
599 diffLines = remainingLines[..<signatureIndex]
600 trailingPlainText = remainingLines[signatureIndex...]
601 .joined(separator: "\n")
602 .trimmingCharacters(in: .whitespacesAndNewlines)
603 } else {
604 diffLines = remainingLines[...]
605 trailingPlainText = ""
606 }
607
608 let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
609 if !diff.isEmpty {
610 blocks.append(.diff(diff))
611 }
612
613 if !trailingPlainText.isEmpty {
614 blocks.append(.plainText(trailingPlainText))
615 }
616 return blocks
617 }
618
619 private nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? {
620 if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) {
621 return explicitDiffIndex
622 }
623
624 for index in lines.indices {
625 let line = lines[index]
626 guard line.hasPrefix("--- ") else { continue }
627 let nextIndex = lines.index(after: index)
628 guard nextIndex < lines.endIndex else { continue }
629 let nextLine = lines[nextIndex]
630 guard nextLine.hasPrefix("+++ ") else { continue }
631
632 let oldPath = String(line.dropFirst(4))
633 let newPath = String(nextLine.dropFirst(4))
634 let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") &&
635 (newPath.hasPrefix("b/") || newPath == "/dev/null")
636
637 if looksLikeUnifiedDiff {
638 return index
639 }
640 }
641
642 return nil
643 }
644
645 private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool {
646 line == "-- " || line == "--"
647 }
648
649 private nonisolated static func normalizeLineEndings(in text: String) -> String {
650 text
651 .replacingOccurrences(of: "\r\n", with: "\n")
652 .replacingOccurrences(of: "\r", with: "\n")
653 }
654
655 private static func stripLeadingFromLineIfPresent(in body: String) -> String {
656 let lines = body.components(separatedBy: "\n")
657 guard let firstLine = lines.first, firstLine.hasPrefix("From:") else {
658 return body
659 }
660
661 var remainingLines = Array(lines.dropFirst())
662 if let nextLine = remainingLines.first, nextLine.isEmpty {
663 remainingLines.removeFirst()
664 }
665 return remainingLines.joined(separator: "\n")
666 }
667
668 private static func leadingHeaderValue(named headerName: String, in body: String) -> String? {
669 let prefix = "\(headerName):"
670 let lines = body.components(separatedBy: .newlines)
671 for line in lines.prefix(12) {
672 if line.isEmpty {
673 break
674 }
675 if line.hasPrefix(prefix) {
676 return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
677 }
678 }
679 return nil
680 }
681
682 private static func executeGraphQLRequest<T: Decodable>(
683 client: SRHTClient,
684 query: String,
685 variables: [String: any Sendable]
686 ) async throws -> T {
687 try await client.execute(
688 service: .lists,
689 query: query,
690 variables: variables,
691 responseType: T.self
692 )
693 }
694
695 private static func isRecoverableNoRows(_ error: Error) -> Bool {
696 guard case let SRHTError.graphQLErrors(errors) = error else {
697 return false
698 }
699 return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") }
700 }
701}