krz/hutch

an ios client for sourcehut

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

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