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 /// Identity is per-thread, keyed on the root Message-ID. It deliberately differs /// from ``threadGroupingKey``, which is subject-based so replies collapse into /// one conversation — two unrelated threads can share a subject on the same list. var id: String { "\(listRID)#\(rootMessageID)" } 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)" } nonisolated var threadGroupingKey: String { "\(listRID)#\(Self.normalizationKey(for: subject))" } nonisolated static func normalizationKey(for subject: String) -> String { normalizedSubject(from: subject).lowercased() } private nonisolated 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, Identifiable { 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" private static let baselineKey = "InboxUnreadBaseline" 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) } /// Records an explicit unread marker rather than forgetting the thread. /// /// Deleting the entry would drop the thread back to the baseline rule below, /// which would call anything older than the baseline read — so marking an old /// thread unread would appear to do nothing. `distantPast` always compares as /// older than the thread's activity, so the thread reads as unread. static func markUnread(for threadID: String, defaults: UserDefaults = .standard) { var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] dictionary[threadID] = Date.distantPast.timeIntervalSince1970 defaults.set(dictionary, forKey: key) } /// Mail that arrived before this is treated as already read. static func baseline(defaults: UserDefaults = .standard) -> Date? { guard let timestamp = defaults.object(forKey: baselineKey) as? TimeInterval else { return nil } return Date(timeIntervalSince1970: timestamp) } /// Sets the point from which mail counts as unread. Called once per account, /// when the account is activated. /// /// Without this, every thread a list has ever carried is unread on first /// login, because an absent view record reads as unread. On a busy list that /// is thousands of threads, none of which the user has any intention of /// reading. /// /// An account that already has read state has been in use, so it keeps the /// old behavior — a baseline of `distantPast` leaves every existing unread /// thread unread rather than silently marking a real backlog as read. static func establishBaselineIfNeeded(now: Date = .now, defaults: UserDefaults = .standard) { guard defaults.object(forKey: baselineKey) == nil else { return } let hasExistingReadState = !((defaults.dictionary(forKey: key) as? [String: TimeInterval])?.isEmpty ?? true) let baseline = hasExistingReadState ? Date.distantPast : now defaults.set(baseline.timeIntervalSince1970, forKey: baselineKey) } static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool { if let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) { return lastActivityAt > lastViewedAt } if let baseline = baseline(defaults: defaults), lastActivityAt <= baseline { return false } return true } }