krz/hutch

an ios client for sourcehut

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

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