summaryrefslogtreecommitdiff
path: root/Hutch/Models
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-19 01:17:49 -0500
committerChristian Cleberg <[email protected]>2026-03-19 01:17:49 -0500
commit441b69afae30ee6b662be38004fd7b5de1e47302 (patch)
tree610827d41c7e7157290727b69b320f8dce95f08f /Hutch/Models
parentffc0ef0988b5e7d01951c23af85a1cf026b3da9f (diff)
downloadhutch-441b69afae30ee6b662be38004fd7b5de1e47302.tar.gz
hutch-441b69afae30ee6b662be38004fd7b5de1e47302.tar.bz2
hutch-441b69afae30ee6b662be38004fd7b5de1e47302.zip
feat: add support for reading inbox messages (emails) and replying in-app
Diffstat (limited to 'Hutch/Models')
-rw-r--r--Hutch/Models/Inbox.swift187
1 files changed, 187 insertions, 0 deletions
diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift
new file mode 100644
index 0000000..720c71f
--- /dev/null
+++ b/Hutch/Models/Inbox.swift
@@ -0,0 +1,187 @@
+import Foundation
+
+struct InboxThreadSummary: Identifiable, Hashable, Sendable {
+ let rootEmailID: Int
+ let rootMessageID: String
+ let threadRootEmailIDs: [Int]
+ let threadRootMessageIDs: [String]
+ let listID: Int
+ let listRID: String
+ let listName: String
+ let listOwner: Entity
+ let subject: String
+ let latestSender: Entity
+ let lastActivityAt: Date
+ let messageCount: Int?
+ let repo: String?
+ let containsPatch: Bool
+ let isUnread: Bool
+
+ var id: String {
+ threadGroupingKey
+ }
+
+ var listDisplayName: String {
+ "\(listOwner.canonicalName)/\(listName)"
+ }
+
+ var displaySubject: String {
+ Self.normalizedSubject(from: subject)
+ }
+
+ var metadataLine: String {
+ var parts = [latestSenderDisplayName]
+ if let messageCount, messageCount > 1 {
+ let replyCount = max(messageCount - 1, 1)
+ parts.append("\(replyCount) repl\(replyCount == 1 ? "y" : "ies")")
+ }
+ parts.append(lastActivityAt.relativeDescription)
+ return parts.joined(separator: " • ")
+ }
+
+ var latestSenderDisplayName: String {
+ let canonicalName = latestSender.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
+ if canonicalName.contains("@") {
+ return canonicalName
+ }
+ return canonicalName
+ }
+
+ var debugIdentifierSummary: String {
+ "subject=\(subject) listRID=\(listRID) listID=\(listID) rootEmailID=\(rootEmailID) rootMessageID=\(rootMessageID) groupingKey=\(threadGroupingKey)"
+ }
+
+ var threadGroupingKey: String {
+ "\(listRID)#\(displaySubject.lowercased())"
+ }
+
+ private static func normalizedSubject(from subject: String) -> String {
+ let collapsedWhitespace = subject
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ let pattern = #"^(?:(?:re|fwd?)\s*:\s*)+"#
+ return collapsedWhitespace.replacingOccurrences(
+ of: pattern,
+ with: "",
+ options: [.regularExpression, .caseInsensitive]
+ )
+ }
+}
+
+struct InboxMessage: Identifiable, Hashable, Sendable {
+ let id: Int
+ let author: Entity
+ let date: Date
+ let subject: String
+ let body: String
+ let senderDisplayName: String
+ let senderEmailAddress: String?
+ let isPatch: Bool
+ let contentBlocks: [InboxMessageContentBlock]
+ let rawMessageURL: URL?
+}
+
+enum InboxMessageContentBlock: Hashable, Sendable {
+ case plainText(String)
+ case diff(String)
+}
+
+struct InboxThreadDetail: Sendable {
+ let id: String
+ let rootEmailID: Int
+ let rootMessageID: String
+ let subject: String
+ let author: Entity
+ let lastActivityAt: Date
+ let mailto: String?
+ let listID: Int
+ let listRID: String
+ let listName: String
+ let listOwner: Entity
+ let messageCount: Int?
+ let messages: [InboxMessage]
+
+ var listDisplayName: String {
+ "\(listOwner.canonicalName)/\(listName)"
+ }
+}
+
+extension InboxThreadDetail {
+ var replyRecipient: String {
+ "\(listOwner.canonicalName)/\(listName)@lists.sr.ht"
+ }
+
+ var replySubject: String {
+ subject.lowercased().hasPrefix("re:") ? subject : "Re: \(subject)"
+ }
+
+ var displaySubject: String {
+ InboxThreadSummary(
+ rootEmailID: rootEmailID,
+ rootMessageID: rootMessageID,
+ threadRootEmailIDs: [rootEmailID],
+ threadRootMessageIDs: [rootMessageID],
+ listID: listID,
+ listRID: listRID,
+ listName: listName,
+ listOwner: listOwner,
+ subject: subject,
+ latestSender: author,
+ lastActivityAt: lastActivityAt,
+ messageCount: messageCount,
+ repo: nil,
+ containsPatch: messages.contains(where: \.isPatch),
+ isUnread: false
+ ).displaySubject
+ }
+}
+
+struct MailComposeDraft: Sendable {
+ let recipients: [String]
+ let ccRecipients: [String]
+ let subject: String
+ let body: String
+
+ var id: String {
+ ([subject] + recipients + ccRecipients).joined(separator: "|")
+ }
+}
+
+extension MailComposeDraft: Identifiable {}
+
+struct InboxMailingListReference: Decodable, Sendable, Hashable {
+ let id: Int
+ let rid: String
+ let name: String
+ let owner: Entity
+}
+
+struct InboxPatchPreview: Decodable, Sendable, Hashable {
+ let subject: String?
+}
+
+enum InboxReadStateStore {
+ private static let key = "InboxThreadLastViewed"
+
+ static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? {
+ guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval],
+ let timestamp = dictionary[threadID] else {
+ return nil
+ }
+ return Date(timeIntervalSince1970: timestamp)
+ }
+
+ static func markViewed(_ date: Date, for threadID: String, defaults: UserDefaults = .standard) {
+ var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
+ dictionary[threadID] = date.timeIntervalSince1970
+ defaults.set(dictionary, forKey: key)
+ }
+
+ static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
+ guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
+ return true
+ }
+ return lastActivityAt > lastViewedAt
+ }
+}