krz/hutch

an ios client for sourcehut

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

v3.5.0: Hutch/Models/Inbox.swift · raw

  1import Foundation
  2
  3struct InboxThreadSummary: Identifiable, Hashable, Sendable {
  4    let rootEmailID: Int
  5    let rootMessageID: String
  6    let threadRootEmailIDs: [Int]
  7    let threadRootMessageIDs: [String]
  8    let listID: Int
  9    let listRID: String
 10    let listName: String
 11    let listOwner: Entity
 12    let subject: String
 13    let latestSender: Entity
 14    let lastActivityAt: Date
 15    let messageCount: Int?
 16    let repo: String?
 17    let containsPatch: Bool
 18    let isUnread: Bool
 19
 20    /// Identity is per-thread, keyed on the root Message-ID. It deliberately differs
 21    /// from ``threadGroupingKey``, which is subject-based so replies collapse into
 22    /// one conversation  two unrelated threads can share a subject on the same list.
 23    var id: String {
 24        "\(listRID)#\(rootMessageID)"
 25    }
 26
 27    var listDisplayName: String {
 28        "\(listOwner.canonicalName)/\(listName)"
 29    }
 30
 31    var displaySubject: String {
 32        Self.normalizedSubject(from: subject)
 33    }
 34
 35    var metadataLine: String {
 36        var parts = [latestSenderDisplayName]
 37        if let messageCount, messageCount > 1 {
 38            let replyCount = max(messageCount - 1, 1)
 39            parts.append("\(replyCount) repl\(replyCount == 1 ? "y" : "ies")")
 40        }
 41        parts.append(lastActivityAt.relativeDescription)
 42        return parts.joined(separator: "")
 43    }
 44
 45    var latestSenderDisplayName: String {
 46        let canonicalName = latestSender.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
 47        if canonicalName.contains("@") {
 48            return canonicalName
 49        }
 50        return canonicalName
 51    }
 52
 53    var debugIdentifierSummary: String {
 54        "subject=\(subject) listRID=\(listRID) listID=\(listID) rootEmailID=\(rootEmailID) rootMessageID=\(rootMessageID) groupingKey=\(threadGroupingKey)"
 55    }
 56
 57    nonisolated var threadGroupingKey: String {
 58        "\(listRID)#\(Self.normalizationKey(for: subject))"
 59    }
 60
 61    nonisolated static func normalizationKey(for subject: String) -> String {
 62        normalizedSubject(from: subject).lowercased()
 63    }
 64
 65    private nonisolated static func normalizedSubject(from subject: String) -> String {
 66        let collapsedWhitespace = subject
 67            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
 68            .trimmingCharacters(in: .whitespacesAndNewlines)
 69
 70        let pattern = #"^(?:(?:re|fwd?)\s*:\s*)+"#
 71        return collapsedWhitespace.replacingOccurrences(
 72            of: pattern,
 73            with: "",
 74            options: [.regularExpression, .caseInsensitive]
 75        )
 76    }
 77}
 78
 79struct InboxMessage: Identifiable, Hashable, Sendable {
 80    let id: Int
 81    let author: Entity
 82    let date: Date
 83    let subject: String
 84    let body: String
 85    let senderDisplayName: String
 86    let senderEmailAddress: String?
 87    let isPatch: Bool
 88    let contentBlocks: [InboxMessageContentBlock]
 89    let rawMessageURL: URL?
 90}
 91
 92enum InboxMessageContentBlock: Hashable, Sendable {
 93    case plainText(String)
 94    case diff(String)
 95}
 96
 97struct InboxThreadDetail: Sendable {
 98    let id: String
 99    let rootEmailID: Int
100    let rootMessageID: String
101    let subject: String
102    let author: Entity
103    let lastActivityAt: Date
104    let mailto: String?
105    let listID: Int
106    let listRID: String
107    let listName: String
108    let listOwner: Entity
109    let messageCount: Int?
110    let messages: [InboxMessage]
111
112    var listDisplayName: String {
113        "\(listOwner.canonicalName)/\(listName)"
114    }
115}
116
117extension InboxThreadDetail {
118    var replyRecipient: String {
119        "\(listOwner.canonicalName)/\(listName)@lists.sr.ht"
120    }
121
122    var replySubject: String {
123        subject.lowercased().hasPrefix("re:") ? subject : "Re: \(subject)"
124    }
125
126    var displaySubject: String {
127        InboxThreadSummary(
128            rootEmailID: rootEmailID,
129            rootMessageID: rootMessageID,
130            threadRootEmailIDs: [rootEmailID],
131            threadRootMessageIDs: [rootMessageID],
132            listID: listID,
133            listRID: listRID,
134            listName: listName,
135            listOwner: listOwner,
136            subject: subject,
137            latestSender: author,
138            lastActivityAt: lastActivityAt,
139            messageCount: messageCount,
140            repo: nil,
141            containsPatch: messages.contains(where: \.isPatch),
142            isUnread: false
143        ).displaySubject
144    }
145}
146
147struct MailComposeDraft: Sendable {
148    let recipients: [String]
149    let ccRecipients: [String]
150    let subject: String
151    let body: String
152
153    var id: String {
154        ([subject] + recipients + ccRecipients).joined(separator: "|")
155    }
156}
157
158extension MailComposeDraft: Identifiable {}
159
160struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable {
161    let id: Int
162    let rid: String
163    let name: String
164    let owner: Entity
165}
166
167struct InboxPatchPreview: Decodable, Sendable, Hashable {
168    let subject: String?
169}
170
171enum InboxReadStateStore {
172    private static let key = "InboxThreadLastViewed"
173
174    static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? {
175        guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval],
176              let timestamp = dictionary[threadID] else {
177            return nil
178        }
179        return Date(timeIntervalSince1970: timestamp)
180    }
181
182    static func markViewed(_ date: Date, for threadID: String, defaults: UserDefaults = .standard) {
183        var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
184        dictionary[threadID] = date.timeIntervalSince1970
185        defaults.set(dictionary, forKey: key)
186    }
187
188    static func markUnread(for threadID: String, defaults: UserDefaults = .standard) {
189        var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
190        dictionary.removeValue(forKey: threadID)
191        defaults.set(dictionary, forKey: key)
192    }
193
194    static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
195        guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
196            return true
197        }
198        return lastActivityAt > lastViewedAt
199    }
200}