krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.12.0: 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}