krz/hutch

an ios client for sourcehut

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

v3.11.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    private static let baselineKey = "InboxUnreadBaseline"
174
175    static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? {
176        guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval],
177              let timestamp = dictionary[threadID] else {
178            return nil
179        }
180        return Date(timeIntervalSince1970: timestamp)
181    }
182
183    static func markViewed(_ date: Date, for threadID: String, defaults: UserDefaults = .standard) {
184        var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
185        dictionary[threadID] = date.timeIntervalSince1970
186        defaults.set(dictionary, forKey: key)
187    }
188
189    /// Records an explicit unread marker rather than forgetting the thread.
190    ///
191    /// Deleting the entry would drop the thread back to the baseline rule below,
192    /// which would call anything older than the baseline read  so marking an old
193    /// thread unread would appear to do nothing. `distantPast` always compares as
194    /// older than the thread's activity, so the thread reads as unread.
195    static func markUnread(for threadID: String, defaults: UserDefaults = .standard) {
196        var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
197        dictionary[threadID] = Date.distantPast.timeIntervalSince1970
198        defaults.set(dictionary, forKey: key)
199    }
200
201    /// Mail that arrived before this is treated as already read.
202    static func baseline(defaults: UserDefaults = .standard) -> Date? {
203        guard let timestamp = defaults.object(forKey: baselineKey) as? TimeInterval else {
204            return nil
205        }
206        return Date(timeIntervalSince1970: timestamp)
207    }
208
209    /// Sets the point from which mail counts as unread. Called once per account,
210    /// when the account is activated.
211    ///
212    /// Without this, every thread a list has ever carried is unread on first
213    /// login, because an absent view record reads as unread. On a busy list that
214    /// is thousands of threads, none of which the user has any intention of
215    /// reading.
216    ///
217    /// An account that already has read state has been in use, so it keeps the
218    /// old behavior  a baseline of `distantPast` leaves every existing unread
219    /// thread unread rather than silently marking a real backlog as read.
220    static func establishBaselineIfNeeded(now: Date = .now, defaults: UserDefaults = .standard) {
221        guard defaults.object(forKey: baselineKey) == nil else { return }
222
223        let hasExistingReadState = !((defaults.dictionary(forKey: key) as? [String: TimeInterval])?.isEmpty ?? true)
224        let baseline = hasExistingReadState ? Date.distantPast : now
225        defaults.set(baseline.timeIntervalSince1970, forKey: baselineKey)
226    }
227
228    static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
229        if let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) {
230            return lastActivityAt > lastViewedAt
231        }
232        if let baseline = baseline(defaults: defaults), lastActivityAt <= baseline {
233            return false
234        }
235        return true
236    }
237}