diff options
| author | Christian Cleberg <[email protected]> | 2026-03-19 15:18:38 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-19 15:18:38 -0500 |
| commit | 659c76df9ff3926cb68886c16167808c23bd8f75 (patch) | |
| tree | 572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch/Models | |
| parent | ed48e72520c51e4635cd3840dbc569bdd74c950f (diff) | |
| parent | 6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (diff) | |
| download | hutch-2.tar.gz hutch-2.tar.bz2 hutch-2.zip | |
v2.0: Merge branch 'dev'v2
Diffstat (limited to 'Hutch/Models')
| -rw-r--r-- | Hutch/Models/Inbox.swift | 193 | ||||
| -rw-r--r-- | Hutch/Models/Paste.swift | 29 | ||||
| -rw-r--r-- | Hutch/Models/Project.swift | 101 |
3 files changed, 323 insertions, 0 deletions
diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift new file mode 100644 index 0000000..c89cb12 --- /dev/null +++ b/Hutch/Models/Inbox.swift @@ -0,0 +1,193 @@ +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, 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" + + 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 markUnread(for threadID: String, defaults: UserDefaults = .standard) { + var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] + dictionary.removeValue(forKey: threadID) + 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 + } +} diff --git a/Hutch/Models/Paste.swift b/Hutch/Models/Paste.swift new file mode 100644 index 0000000..b4c8f18 --- /dev/null +++ b/Hutch/Models/Paste.swift @@ -0,0 +1,29 @@ +import Foundation + +struct PasteFile: Codable, Sendable, Hashable, Identifiable { + let filename: String? + let hash: String + let contents: URL? + + var id: String { hash } +} + +struct Paste: Codable, Sendable, Identifiable, Hashable { + let id: String + let created: Date + let visibility: Visibility + let files: [PasteFile] + let user: Entity +} + +struct PasteUploadDraft: Identifiable, Equatable, Sendable { + let id: UUID + var filename: String + var contents: String + + init(id: UUID = UUID(), filename: String = "", contents: String = "") { + self.id = id + self.filename = filename + self.contents = contents + } +} diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift new file mode 100644 index 0000000..e6726c2 --- /dev/null +++ b/Hutch/Models/Project.swift @@ -0,0 +1,101 @@ +import Foundation + +struct Project: Identifiable, Hashable, Sendable { + struct MailingList: Identifiable, Hashable, Sendable { + let id: String + let name: String + let description: String? + let visibility: Visibility + let owner: Entity + + var ownerUsername: String { + owner.canonicalName.srhtUsername + } + + var inboxReference: InboxMailingListReference { + InboxMailingListReference( + id: 0, + rid: id, + name: name, + owner: owner + ) + } + } + + struct SourceRepo: Identifiable, Hashable, Sendable { + enum RepoType: String, Decodable, Sendable { + case git = "GIT" + case hg = "HG" + + var service: SRHTService { + switch self { + case .git: .git + case .hg: .hg + } + } + } + + let id: String + let name: String + let description: String? + let visibility: Visibility + let owner: Entity + let repoType: RepoType + + var ownerUsername: String { + owner.canonicalName.srhtUsername + } + } + + struct Tracker: Identifiable, Hashable, Sendable { + let id: String + let name: String + let description: String? + let visibility: Visibility + let owner: Entity + + var ownerUsername: String { + owner.canonicalName.srhtUsername + } + } + + let id: String + let name: String + let description: String? + let website: String? + let visibility: Visibility + let tags: [String] + let mailingLists: [MailingList] + let sources: [SourceRepo] + let trackers: [Tracker] + + var resourceSummary: String? { + let parts = [ + Self.resourceCountText(count: sources.count, singular: "repo"), + Self.resourceCountText(count: trackers.count, singular: "tracker"), + Self.resourceCountText(count: mailingLists.count, singular: "list") + ].compactMap { $0 } + + if !parts.isEmpty { + return parts.joined(separator: " • ") + } + + if let website, !website.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Website linked" + } + + return nil + } + + private static func resourceCountText(count: Int, singular: String) -> String? { + guard count > 0 else { return nil } + let label = count == 1 ? singular : "\(singular)s" + return "\(count) \(label)" + } +} + +extension String { + var srhtUsername: String { + hasPrefix("~") ? String(dropFirst()) : self + } +} |
