krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.6.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
174 static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? {
175 guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval],
176 let timestamp = dictionary[threadID] else {
177 return nil
178 }
179 return Date(timeIntervalSince1970: timestamp)
180 }
181
182 static func markViewed(_ date: Date, for threadID: String, defaults: UserDefaults = .standard) {
183 var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
184 dictionary[threadID] = date.timeIntervalSince1970
185 defaults.set(dictionary, forKey: key)
186 }
187
188 static func markUnread(for threadID: String, defaults: UserDefaults = .standard) {
189 var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
190 dictionary.removeValue(forKey: threadID)
191 defaults.set(dictionary, forKey: key)
192 }
193
194 static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
195 guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
196 return true
197 }
198 return lastActivityAt > lastViewedAt
199 }
200}