krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.10.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}