krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.14.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)#\(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}