summaryrefslogtreecommitdiff
path: root/Hutch/Models
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Models')
-rw-r--r--Hutch/Models/Inbox.swift193
-rw-r--r--Hutch/Models/Paste.swift29
-rw-r--r--Hutch/Models/Project.swift101
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
+ }
+}