summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-19 15:18:38 -0500
committerChristian Cleberg <[email protected]>2026-03-19 15:18:38 -0500
commit659c76df9ff3926cb68886c16167808c23bd8f75 (patch)
tree572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7
parented48e72520c51e4635cd3840dbc569bdd74c950f (diff)
parent6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (diff)
downloadhutch-659c76df9ff3926cb68886c16167808c23bd8f75.tar.gz
hutch-659c76df9ff3926cb68886c16167808c23bd8f75.tar.bz2
hutch-659c76df9ff3926cb68886c16167808c23bd8f75.zip
v2.0: Merge branch 'dev'v2
-rw-r--r--Hutch.xcodeproj/project.pbxproj8
-rw-r--r--Hutch/App/AppState.swift54
-rw-r--r--Hutch/App/RootView.swift151
-rw-r--r--Hutch/Extensions/SRHTShareUI.swift1
-rw-r--r--Hutch/Extensions/SRHTWebURL.swift8
-rw-r--r--Hutch/Models/Inbox.swift193
-rw-r--r--Hutch/Models/Paste.swift29
-rw-r--r--Hutch/Models/Project.swift101
-rw-r--r--Hutch/Networking/GraphQLRequest.swift2
-rw-r--r--Hutch/Networking/PasteService.swift231
-rw-r--r--Hutch/Networking/ProjectService.swift284
-rw-r--r--Hutch/Networking/SRHTClient.swift101
-rw-r--r--Hutch/Networking/SRHTService.swift11
-rw-r--r--Hutch/Views/Home/HomeView.swift419
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift627
-rw-r--r--Hutch/Views/Inbox/InboxView.swift125
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift371
-rw-r--r--Hutch/Views/Inbox/ThreadDetailView.swift374
-rw-r--r--Hutch/Views/Inbox/ThreadViewModel.swift760
-rw-r--r--Hutch/Views/Lists/MailingListListView.swift159
-rw-r--r--Hutch/Views/More/MoreView.swift40
-rw-r--r--Hutch/Views/Pastes/PasteDetailView.swift288
-rw-r--r--Hutch/Views/Pastes/PasteDetailViewModel.swift114
-rw-r--r--Hutch/Views/Pastes/PasteListView.swift280
-rw-r--r--Hutch/Views/Pastes/PasteListViewModel.swift109
-rw-r--r--Hutch/Views/Projects/ProjectDetailView.swift135
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift282
-rw-r--r--Hutch/Views/Repositories/DiffView.swift157
-rw-r--r--Hutch/Views/Repositories/RepositoryListView.swift5
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift185
-rw-r--r--Hutch/Views/Repositories/RepositoryRowView.swift45
-rw-r--r--Hutch/Views/Repositories/RepositorySummarySupport.swift7
-rw-r--r--Hutch/Views/Settings/SettingsView.swift26
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift51
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift80
-rw-r--r--HutchTests/HomeViewModelTests.swift67
-rw-r--r--HutchTests/InboxViewModelTests.swift168
-rw-r--r--HutchTests/ProjectTests.swift72
-rw-r--r--HutchTests/RepositoryListViewModelTests.swift26
39 files changed, 6082 insertions, 64 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 6368778..23a2d62 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -363,7 +363,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2;
+ CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -380,7 +380,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.2;
+ MARKETING_VERSION = 2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -399,7 +399,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2;
+ CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -416,7 +416,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.2;
+ MARKETING_VERSION = 2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 624c78d..d5a86fd 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -7,6 +7,20 @@ import WebKit
@MainActor
final class AppState {
+ enum Tab: Hashable {
+ case home
+ case repositories
+ case tickets
+ case builds
+ case more
+ }
+
+ enum TabNavigationTarget: Hashable {
+ case repository(RepositorySummary)
+ case tracker(TrackerSummary)
+ case mailingList(InboxMailingListReference)
+ }
+
enum AuthPhase {
/// App just launched, checking for an existing token.
case launching
@@ -25,6 +39,8 @@ final class AppState {
authPhase == .authenticated && currentUser != nil
}
+ var selectedTab: Tab = .home
+
// MARK: - Current user (populated after successful validation)
private(set) var currentUser: User?
@@ -37,6 +53,7 @@ final class AppState {
/// Set by the deep link handler; consumed by RootView to drive navigation.
var pendingDeepLink: DeepLink?
+ var pendingTabNavigation: TabNavigationTarget?
// MARK: - Init
@@ -94,6 +111,7 @@ final class AppState {
await clearWebData()
clearWebContentRenderCaches()
authPhase = .unauthenticated
+ selectedTab = .home
}
func resetAppData() async {
@@ -108,14 +126,15 @@ final class AppState {
clearWebContentRenderCaches()
authPhase = .unauthenticated
+ selectedTab = .home
}
// MARK: - Deep link resolution
/// Resolve a repository by owner and name for deep linking.
- func resolveRepository(owner: String, name: String) async throws -> RepositorySummary {
+ func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
let result = try await client.execute(
- service: .git,
+ service: service,
query: Self.repoLookupQuery,
variables: ["owner": owner, "name": name],
responseType: RepoLookupResponse.self
@@ -134,6 +153,35 @@ final class AppState {
return result.user.tracker
}
+ func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
+ try await resolveRepository(
+ owner: source.ownerUsername,
+ name: source.name,
+ service: source.repoType.service
+ )
+ }
+
+ func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary {
+ try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name)
+ }
+
+ func openProjectSource(_ source: Project.SourceRepo) async throws {
+ let repository = try await resolveProjectSource(source)
+ pendingTabNavigation = .repository(repository)
+ selectedTab = .repositories
+ }
+
+ func openProjectTracker(_ tracker: Project.Tracker) async throws {
+ let resolvedTracker = try await resolveProjectTracker(tracker)
+ pendingTabNavigation = .tracker(resolvedTracker)
+ selectedTab = .tickets
+ }
+
+ func openMailingList(_ mailingList: InboxMailingListReference) {
+ pendingTabNavigation = .mailingList(mailingList)
+ selectedTab = .more
+ }
+
// MARK: - Private
private static let meQuery = """
@@ -208,6 +256,8 @@ final class AppState {
client.responseCache.clear()
currentUser = nil
pendingDeepLink = nil
+ pendingTabNavigation = nil
+ selectedTab = .home
}
private func clearWebData() async {
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 7af162d..0885835 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -4,15 +4,8 @@ import SwiftUI
/// full-screen sheet for token entry on first launch.
struct RootView: View {
@Environment(AppState.self) private var appState
-
- enum Tab: Hashable {
- case repositories
- case builds
- case tickets
- case settings
- }
-
- @State private var selectedTab: Tab = .repositories
+ @State private var homePath = NavigationPath()
+ @State private var morePath = NavigationPath()
@State private var repoPath = NavigationPath()
@State private var buildsPath = NavigationPath()
@State private var ticketsPath = NavigationPath()
@@ -41,20 +34,45 @@ struct RootView: View {
.onChange(of: appState.authPhase) { _, newPhase in
handleAuthPhaseChange(newPhase)
}
+ .onChange(of: appState.pendingTabNavigation) { _, newValue in
+ consumePendingTabNavigationIfPossible(newValue)
+ }
}
// MARK: - Tab View
private var tabContent: some View {
- TabView(selection: $selectedTab) {
+ @Bindable var appState = appState
+
+ return TabView(selection: $appState.selectedTab) {
+ NavigationStack(path: $homePath) {
+ HomeView()
+ }
+ .tag(AppState.Tab.home)
+ .tabItem {
+ Label("Home", systemImage: "house")
+ }
+
NavigationStack(path: $repoPath) {
RepositoryListView()
}
- .tag(Tab.repositories)
+ .tag(AppState.Tab.repositories)
.tabItem {
Label("Repositories", systemImage: "book.closed")
}
+ NavigationStack(path: $ticketsPath) {
+ TrackerListView()
+ // Deep link destination for jumping straight to a ticket.
+ .navigationDestination(for: TicketDeepLinkTarget.self) { target in
+ TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
+ }
+ }
+ .tag(AppState.Tab.tickets)
+ .tabItem {
+ Label("Tickets", systemImage: "ticket")
+ }
+
NavigationStack(path: $buildsPath) {
BuildListView()
// Int destination used by deep links (hutch://builds/<id>).
@@ -63,28 +81,18 @@ struct RootView: View {
BuildDetailView(jobId: jobId)
}
}
- .tag(Tab.builds)
+ .tag(AppState.Tab.builds)
.tabItem {
Label("Builds", systemImage: "hammer")
}
- NavigationStack(path: $ticketsPath) {
- TrackerListView()
- // Deep link destination for jumping straight to a ticket.
- .navigationDestination(for: TicketDeepLinkTarget.self) { target in
- TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
- }
+ NavigationStack(path: $morePath) {
+ MoreNavigationRoot()
}
- .tag(Tab.tickets)
+ .tag(AppState.Tab.more)
.tabItem {
- Label("Tickets", systemImage: "ticket")
+ Label("More", systemImage: "ellipsis.circle")
}
-
- SettingsView()
- .tag(Tab.settings)
- .tabItem {
- Label("Settings", systemImage: "gear")
- }
}
.overlay {
if isResolvingDeepLink {
@@ -106,10 +114,12 @@ struct RootView: View {
case .launching:
break
case .unauthenticated:
+ homePath = NavigationPath()
+ morePath = NavigationPath()
repoPath = NavigationPath()
buildsPath = NavigationPath()
ticketsPath = NavigationPath()
- selectedTab = .repositories
+ appState.selectedTab = .home
isResolvingDeepLink = false
case .authenticated:
consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
@@ -122,6 +132,12 @@ struct RootView: View {
appState.pendingDeepLink = nil
}
+ private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
+ guard appState.isAuthenticated, let target else { return }
+ handleTabNavigation(target)
+ appState.pendingTabNavigation = nil
+ }
+
private func handleDeepLink(_ link: DeepLink) {
guard appState.isAuthenticated else { return }
@@ -132,7 +148,7 @@ struct RootView: View {
case .build(let jobId):
// Reset the builds navigation and push the detail
buildsPath = NavigationPath()
- selectedTab = .builds
+ appState.selectedTab = .builds
// Defer the push slightly so the tab switch takes effect
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
@@ -144,6 +160,36 @@ struct RootView: View {
}
}
+ private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
+ switch target {
+ case .repository(let repository):
+ repoPath = NavigationPath()
+ appState.selectedTab = .repositories
+ Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(100))
+ repoPath.append(repository)
+ }
+
+ case .tracker(let tracker):
+ ticketsPath = NavigationPath()
+ appState.selectedTab = .tickets
+ Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(100))
+ ticketsPath.append(tracker)
+ }
+
+ case .mailingList(let mailingList):
+ morePath = NavigationPath()
+ appState.selectedTab = .more
+ Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(100))
+ morePath.append(MoreRoute.lists)
+ try? await Task.sleep(for: .milliseconds(100))
+ morePath.append(MoreRoute.mailingList(mailingList))
+ }
+ }
+ }
+
private func resolveRepositoryLink(owner: String, repo: String) {
isResolvingDeepLink = true
Task {
@@ -151,7 +197,7 @@ struct RootView: View {
do {
let summary = try await appState.resolveRepository(owner: owner, name: repo)
repoPath = NavigationPath()
- selectedTab = .repositories
+ appState.selectedTab = .repositories
try? await Task.sleep(for: .milliseconds(100))
repoPath.append(summary)
} catch {
@@ -167,7 +213,7 @@ struct RootView: View {
do {
let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
ticketsPath = NavigationPath()
- selectedTab = .tickets
+ appState.selectedTab = .tickets
try? await Task.sleep(for: .milliseconds(100))
ticketsPath.append(trackerSummary)
try? await Task.sleep(for: .milliseconds(100))
@@ -185,6 +231,51 @@ struct RootView: View {
}
}
+enum MoreDestination: Hashable {
+ case lists
+ case pastes
+ case settings
+}
+
+enum MoreRoute: Hashable {
+ case lists
+ case pastes
+ case settings
+ case mailingList(InboxMailingListReference)
+ case thread(InboxThreadSummary)
+}
+
+private struct MoreNavigationRoot: View {
+ var body: some View {
+ MoreView()
+ .navigationDestination(for: MoreRoute.self) { route in
+ switch route {
+ case .lists:
+ MailingListListView()
+ case .pastes:
+ PasteListView()
+ case .settings:
+ SettingsView()
+ case .mailingList(let mailingList):
+ MailingListDetailView(mailingList: mailingList)
+ case .thread(let thread):
+ ThreadDetailView(
+ thread: thread,
+ onViewed: {
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+ },
+ onMarkRead: {
+ InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+ },
+ onMarkUnread: {
+ InboxReadStateStore.markUnread(for: thread.id)
+ }
+ )
+ }
+ }
+ }
+}
+
// MARK: - Ticket Deep Link Navigation Target
/// Hashable wrapper to push a ticket detail view from a deep link.
diff --git a/Hutch/Extensions/SRHTShareUI.swift b/Hutch/Extensions/SRHTShareUI.swift
index 9d949c9..01f3d91 100644
--- a/Hutch/Extensions/SRHTShareUI.swift
+++ b/Hutch/Extensions/SRHTShareUI.swift
@@ -9,6 +9,7 @@ enum SRHTShareTarget: String {
case tracker = "tracker"
case ticket = "ticket"
case profile = "profile"
+ case paste = "paste"
var fallbackMessage: String {
"This \(rawValue) does not have a valid web URL to share."
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index f63352b..35d250d 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -76,6 +76,14 @@ enum SRHTWebURL {
)
}
+ static func paste(ownerCanonicalName: String, pasteId: String) -> URL? {
+ userScopedURL(
+ host: "paste.sr.ht",
+ ownerCanonicalName: ownerCanonicalName,
+ pathComponents: [pasteId]
+ )
+ }
+
private static func userScopedURL(
host: String,
ownerCanonicalName: String,
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
+ }
+}
diff --git a/Hutch/Networking/GraphQLRequest.swift b/Hutch/Networking/GraphQLRequest.swift
index bce7b40..8924e6a 100644
--- a/Hutch/Networking/GraphQLRequest.swift
+++ b/Hutch/Networking/GraphQLRequest.swift
@@ -34,6 +34,8 @@ struct AnyCodable: Sendable, Encodable {
try container.encode(v)
case let v as Bool:
try container.encode(v)
+ case let v as [String?]:
+ try container.encode(v)
case let v as [any Sendable]:
try container.encode(v.map { AnyCodable($0) })
case let v as [String: any Sendable]:
diff --git a/Hutch/Networking/PasteService.swift b/Hutch/Networking/PasteService.swift
new file mode 100644
index 0000000..b9aa530
--- /dev/null
+++ b/Hutch/Networking/PasteService.swift
@@ -0,0 +1,231 @@
+import Foundation
+
+struct PasteListPage: Decodable, Sendable {
+ let results: [Paste]
+ let cursor: String?
+}
+
+final class PasteService: Sendable {
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ private static let listQuery = """
+ query pastes($cursor: Cursor) {
+ pastes(cursor: $cursor) {
+ results {
+ id
+ created
+ visibility
+ files {
+ filename
+ hash
+ }
+ user {
+ canonicalName
+ }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let detailQuery = """
+ query paste($id: String!) {
+ paste(id: $id) {
+ id
+ created
+ visibility
+ files {
+ filename
+ hash
+ contents
+ }
+ user {
+ canonicalName
+ }
+ }
+ }
+ """
+
+ private static let createMutation = """
+ mutation createPaste($files: [Upload!]!, $visibility: Visibility!) {
+ create(files: $files, visibility: $visibility) {
+ id
+ created
+ visibility
+ files {
+ filename
+ hash
+ contents
+ }
+ user {
+ canonicalName
+ }
+ }
+ }
+ """
+
+ private static let updateMutation = """
+ mutation updatePaste($id: String!, $visibility: Visibility!) {
+ update(id: $id, visibility: $visibility) {
+ id
+ created
+ visibility
+ files {
+ filename
+ hash
+ contents
+ }
+ user {
+ canonicalName
+ }
+ }
+ }
+ """
+
+ private static let deleteMutation = """
+ mutation deletePaste($id: String!) {
+ delete(id: $id) {
+ id
+ created
+ visibility
+ files {
+ filename
+ hash
+ }
+ user {
+ canonicalName
+ }
+ }
+ }
+ """
+
+ private static let cacheKey = "paste.pastes"
+
+ func listPastes(cursor: String?, useCache: Bool) async throws -> PasteListPage {
+ let variables = cursor.map { ["cursor": $0 as any Sendable] }
+ let result: PasteListResponse
+ if useCache, cursor == nil {
+ result = try await client.executeAndCache(
+ service: .paste,
+ query: Self.listQuery,
+ variables: variables,
+ responseType: PasteListResponse.self,
+ cacheKey: Self.cacheKey
+ )
+ } else {
+ result = try await client.execute(
+ service: .paste,
+ query: Self.listQuery,
+ variables: variables,
+ responseType: PasteListResponse.self
+ )
+ }
+ return result.pastes ?? PasteListPage(results: [], cursor: nil)
+ }
+
+ func loadCachedPastes() -> PasteListPage? {
+ guard let data = client.responseCache.get(forKey: Self.cacheKey) else {
+ return nil
+ }
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ guard let response = try? decoder.decode(GraphQLResponse<PasteListResponse>.self, from: data) else {
+ return nil
+ }
+ return response.data?.pastes
+ }
+
+ func loadPaste(id: String) async throws -> Paste? {
+ let result = try await client.execute(
+ service: .paste,
+ query: Self.detailQuery,
+ variables: ["id": id],
+ responseType: PasteDetailResponse.self
+ )
+ return result.paste
+ }
+
+ func createPaste(files: [PasteUploadDraft], visibility: Visibility) async throws -> Paste {
+ let uploadFiles = normalizedUploadFiles(from: files)
+ let variables: [String: any Sendable] = [
+ "files": [String?](repeating: nil, count: uploadFiles.count),
+ "visibility": visibility.rawValue
+ ]
+
+ let result = try await client.executeMultipartFiles(
+ service: .paste,
+ query: Self.createMutation,
+ variables: variables,
+ files: uploadFiles.enumerated().map { index, file in
+ MultipartUploadFile(
+ variablePath: "files.\(index)",
+ fileData: file.data,
+ fileName: file.fileName,
+ mimeType: "text/plain"
+ )
+ },
+ responseType: CreatePasteResponse.self
+ )
+ return result.create
+ }
+
+ func updateVisibility(id: String, visibility: Visibility) async throws -> Paste? {
+ let result = try await client.execute(
+ service: .paste,
+ query: Self.updateMutation,
+ variables: ["id": id, "visibility": visibility.rawValue],
+ responseType: UpdatePasteResponse.self
+ )
+ return result.update
+ }
+
+ func deletePaste(id: String) async throws -> Paste? {
+ let result = try await client.execute(
+ service: .paste,
+ query: Self.deleteMutation,
+ variables: ["id": id],
+ responseType: DeletePasteResponse.self
+ )
+ return result.delete
+ }
+
+ func loadContents(from url: URL) async throws -> String {
+ try await client.fetchText(url: url)
+ }
+
+ private func normalizedUploadFiles(from files: [PasteUploadDraft]) -> [(fileName: String, data: Data)] {
+ files.compactMap { draft in
+ let text = draft.contents
+ guard let data = text.data(using: .utf8) else {
+ return nil
+ }
+
+ return (draft.filename, data)
+ }
+ }
+}
+
+private struct PasteListResponse: Decodable, Sendable {
+ let pastes: PasteListPage?
+}
+
+private struct PasteDetailResponse: Decodable, Sendable {
+ let paste: Paste?
+}
+
+private struct CreatePasteResponse: Decodable, Sendable {
+ let create: Paste
+}
+
+private struct UpdatePasteResponse: Decodable, Sendable {
+ let update: Paste?
+}
+
+private struct DeletePasteResponse: Decodable, Sendable {
+ let delete: Paste?
+}
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift
new file mode 100644
index 0000000..2afb699
--- /dev/null
+++ b/Hutch/Networking/ProjectService.swift
@@ -0,0 +1,284 @@
+import Foundation
+
+private struct ProjectPageResponse: Decodable, Sendable {
+ let me: ProjectPageUser
+}
+
+private struct ProjectPageUser: Decodable, Sendable {
+ let projects: ProjectPage
+}
+
+private struct ProjectPage: Decodable, Sendable {
+ let results: [ProjectSummaryPayload]
+ let cursor: String?
+}
+
+private struct ProjectSummaryPayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let website: String?
+ let visibility: Visibility
+ let tags: [String]
+}
+
+private struct ProjectDetailResponse: Decodable, Sendable {
+ let project: ProjectDetailPayload?
+}
+
+private struct ProjectDetailPayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let website: String?
+ let visibility: Visibility
+ let tags: [String]
+ let mailingLists: ProjectMailingListPage
+ let sources: ProjectSourcePage
+ let trackers: ProjectTrackerPage
+}
+
+private struct ProjectMailingListPage: Decodable, Sendable {
+ let results: [ProjectMailingListPayload]
+ let cursor: String?
+}
+
+private struct ProjectMailingListPayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let owner: Entity
+}
+
+private struct ProjectSourcePage: Decodable, Sendable {
+ let results: [ProjectSourcePayload]
+ let cursor: String?
+}
+
+private struct ProjectSourcePayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let owner: Entity
+ let repoType: Project.SourceRepo.RepoType
+}
+
+private struct ProjectTrackerPage: Decodable, Sendable {
+ let results: [ProjectTrackerPayload]
+ let cursor: String?
+}
+
+private struct ProjectTrackerPayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let owner: Entity
+}
+
+struct ProjectService: Sendable {
+ private let client: SRHTClient
+
+ private static let projectsQuery = """
+ query meProjects($cursor: Cursor) {
+ me {
+ projects(cursor: $cursor) {
+ results {
+ rid
+ name
+ description
+ website
+ visibility
+ tags
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let projectDetailQuery = """
+ query projectDetail($rid: ID!, $mailingListsCursor: Cursor, $sourcesCursor: Cursor, $trackersCursor: Cursor) {
+ project(rid: $rid) {
+ rid
+ name
+ description
+ website
+ visibility
+ tags
+ mailingLists(cursor: $mailingListsCursor) {
+ results {
+ rid
+ name
+ description
+ visibility
+ owner { canonicalName }
+ }
+ cursor
+ }
+ sources(cursor: $sourcesCursor) {
+ results {
+ rid
+ name
+ description
+ visibility
+ owner { canonicalName }
+ repoType
+ }
+ cursor
+ }
+ trackers(cursor: $trackersCursor) {
+ results {
+ rid
+ name
+ description
+ visibility
+ owner { canonicalName }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ func fetchProjects() async throws -> [Project] {
+ let summaries = try await fetchProjectSummaries()
+ guard !summaries.isEmpty else { return [] }
+
+ return try await withThrowingTaskGroup(of: Project.self) { group in
+ for summary in summaries {
+ group.addTask {
+ try await self.fetchProjectDetail(summary: summary)
+ }
+ }
+
+ var projects: [Project] = []
+ for try await project in group {
+ projects.append(project)
+ }
+ return projects.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ }
+ }
+
+ private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] {
+ var results: [ProjectSummaryPayload] = []
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .hub,
+ query: Self.projectsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: ProjectPageResponse.self
+ )
+
+ results.append(contentsOf: response.me.projects.results)
+ guard let nextCursor = response.me.projects.cursor else {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ return results
+ }
+
+ private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project {
+ var mailingLists: [Project.MailingList] = []
+ var sources: [Project.SourceRepo] = []
+ var trackers: [Project.Tracker] = []
+ var mailingListsCursor: String?
+ var sourcesCursor: String?
+ var trackersCursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = ["rid": summary.rid]
+ if let mailingListsCursor {
+ variables["mailingListsCursor"] = mailingListsCursor
+ }
+ if let sourcesCursor {
+ variables["sourcesCursor"] = sourcesCursor
+ }
+ if let trackersCursor {
+ variables["trackersCursor"] = trackersCursor
+ }
+
+ let response = try await client.execute(
+ service: .hub,
+ query: Self.projectDetailQuery,
+ variables: variables,
+ responseType: ProjectDetailResponse.self
+ )
+
+ guard let project = response.project else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing project payload"))
+ )
+ }
+
+ mailingLists.append(contentsOf: project.mailingLists.results.map {
+ Project.MailingList(
+ id: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ owner: $0.owner
+ )
+ })
+ sources.append(contentsOf: project.sources.results.map {
+ Project.SourceRepo(
+ id: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ owner: $0.owner,
+ repoType: $0.repoType
+ )
+ })
+ trackers.append(contentsOf: project.trackers.results.map {
+ Project.Tracker(
+ id: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ owner: $0.owner
+ )
+ })
+
+ mailingListsCursor = project.mailingLists.cursor
+ sourcesCursor = project.sources.cursor
+ trackersCursor = project.trackers.cursor
+
+ if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
+ return Project(
+ id: project.rid,
+ name: project.name,
+ description: project.description,
+ website: project.website,
+ visibility: project.visibility,
+ tags: project.tags,
+ mailingLists: deduplicate(mailingLists),
+ sources: deduplicate(sources),
+ trackers: deduplicate(trackers)
+ )
+ }
+ }
+ }
+
+ private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
+ var seen = Set<T.ID>()
+ return items.filter { item in
+ seen.insert(item.id).inserted
+ }
+ }
+}
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
index 6032cab..4a6b3ca 100644
--- a/Hutch/Networking/SRHTClient.swift
+++ b/Hutch/Networking/SRHTClient.swift
@@ -3,6 +3,13 @@ import os
private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient")
+struct MultipartUploadFile: Sendable {
+ let variablePath: String
+ let fileData: Data
+ let fileName: String
+ let mimeType: String
+}
+
/// Placeholder type for decoding GraphQL error responses when the data shape is unknown.
private struct EmptyData: Decodable {}
@@ -298,6 +305,100 @@ final class SRHTClient: Sendable {
return result
}
+ func executeMultipartFiles<T: Decodable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable],
+ files: [MultipartUploadFile],
+ responseType: T.Type
+ ) async throws -> T {
+ guard let token = _token.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ let boundary = "Boundary-\(UUID().uuidString)"
+
+ var request = URLRequest(url: service.url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+
+ let operationsBody = GraphQLRequestBody(
+ query: query,
+ variables: variables.mapValues { AnyCodable($0) }
+ )
+ let operationsData = try encoder.encode(operationsBody)
+
+ let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in
+ (String(index), ["variables.\(file.variablePath)"])
+ })
+ let mapData = try encoder.encode(mapDict)
+
+ var body = Data()
+
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
+ body.append("Content-Type: application/json\r\n\r\n")
+ body.append(operationsData)
+ body.append("\r\n")
+
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"map\"\r\n")
+ body.append("Content-Type: application/json\r\n\r\n")
+ body.append(mapData)
+ body.append("\r\n")
+
+ for (index, file) in files.enumerated() {
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"\(index)\"; filename=\"\(file.fileName)\"\r\n")
+ body.append("Content-Type: \(file.mimeType)\r\n\r\n")
+ body.append(file.fileData)
+ body.append("\r\n")
+ }
+
+ body.append("--\(boundary)--\r\n")
+ request.httpBody = body
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
+ let errors = gqlResponse.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ let graphQLResponse: GraphQLResponse<T>
+ do {
+ graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
+ } catch {
+ throw SRHTError.decodingError(error)
+ }
+
+ if let errors = graphQLResponse.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+
+ guard let result = graphQLResponse.data else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
+ )
+ }
+
+ return result
+ }
+
// MARK: - Cached Execute
/// Execute a query and cache the raw response data. Returns cached data
diff --git a/Hutch/Networking/SRHTService.swift b/Hutch/Networking/SRHTService.swift
index 5db06dd..416b733 100644
--- a/Hutch/Networking/SRHTService.swift
+++ b/Hutch/Networking/SRHTService.swift
@@ -3,6 +3,7 @@ import Foundation
/// Each Sourcehut service exposes its own GraphQL endpoint.
enum SRHTService: String, Codable, Sendable, CaseIterable {
case meta
+ case hub
case git
case hg
case builds
@@ -14,13 +15,19 @@ enum SRHTService: String, Codable, Sendable, CaseIterable {
/// The GraphQL endpoint URL for this service.
var url: URL {
- // Force-unwrap is safe here — these are compile-time constant strings.
- URL(string: "https://\(rawValue).sr.ht/query")!
+ switch self {
+ case .hub:
+ URL(string: "https://sr.ht/query")!
+ default:
+ // Force-unwrap is safe here — these are compile-time constant strings.
+ URL(string: "https://\(rawValue).sr.ht/query")!
+ }
}
var displayName: String {
switch self {
case .meta: "Meta"
+ case .hub: "Hub"
case .git: "Git"
case .hg: "Mercurial"
case .builds: "Builds"
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
new file mode 100644
index 0000000..55ec41e
--- /dev/null
+++ b/Hutch/Views/Home/HomeView.swift
@@ -0,0 +1,419 @@
+import SwiftUI
+
+struct HomeView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: HomeViewModel?
+ private let previewLimit = 4
+ private let projectPreviewLimit = 3
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading Home…")
+ }
+ }
+ .navigationTitle("Home")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ NavigationLink {
+ InboxView()
+ } label: {
+ HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
+ }
+ }
+ }
+ .task {
+ if viewModel == nil, let currentUser = appState.currentUser {
+ let vm = HomeViewModel(currentUser: currentUser, client: appState.client)
+ viewModel = vm
+ await vm.loadDashboard()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: HomeViewModel) -> some View {
+ List {
+ projectsSection(viewModel)
+ assignedTicketsSection(viewModel)
+ recentBuildsSection(viewModel)
+ }
+ .listStyle(.insetGrouped)
+ .overlay {
+ if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
+ viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
+ SRHTLoadingStateView(message: "Loading Home…")
+ } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
+ viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
+ viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
+ ContentUnavailableView(
+ "All Clear",
+ systemImage: "checkmark.circle",
+ description: Text("There are no assigned tickets or recent builds right now.")
+ )
+ }
+ }
+ .refreshable {
+ await viewModel.loadDashboard()
+ }
+ }
+
+ @ViewBuilder
+ private func projectsSection(_ viewModel: HomeViewModel) -> some View {
+ if !viewModel.projects.isEmpty {
+ Section {
+ ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
+ NavigationLink {
+ ProjectDetailView(project: project)
+ } label: {
+ HomeProjectRow(project: project)
+ }
+ }
+ } header: {
+ HomeSectionHeader("Projects") {
+ HomeProjectsListView(viewModel: viewModel)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
+ HomeSectionLoadingRow(label: "Loading assigned tickets")
+ } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
+ HomeSectionMessageRow(
+ text: "Couldn’t load assigned tickets.",
+ systemImage: "exclamationmark.triangle",
+ emphasized: true,
+ accessibilityHint: error
+ )
+ } else if viewModel.assignedTickets.isEmpty {
+ HomeSectionMessageRow(
+ text: "No open tickets assigned to you.",
+ systemImage: "person.crop.circle.badge.checkmark"
+ )
+ } else {
+ ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in
+ NavigationLink {
+ TicketDetailView(
+ ownerUsername: ticket.ownerUsername,
+ trackerName: ticket.trackerName,
+ trackerId: ticket.trackerId,
+ trackerRid: ticket.trackerRid,
+ ticketId: ticket.ticket.id
+ )
+ } label: {
+ HomeAssignedTicketRow(ticket: ticket)
+ }
+ }
+ }
+ } header: {
+ HomeSectionHeader("Assigned Tickets") {
+ HomeAssignedTicketsListView(viewModel: viewModel)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
+ HomeSectionLoadingRow(label: "Loading recent builds")
+ } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
+ HomeSectionMessageRow(
+ text: "Couldn’t load recent builds.",
+ systemImage: "exclamationmark.triangle",
+ emphasized: true,
+ accessibilityHint: error
+ )
+ } else if viewModel.recentBuilds.isEmpty {
+ HomeSectionMessageRow(
+ text: "No recent builds.",
+ systemImage: "clock"
+ )
+ } else {
+ ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in
+ NavigationLink {
+ BuildDetailView(jobId: build.job.id)
+ } label: {
+ HomeBuildRow(build: build)
+ }
+ }
+ }
+ } header: {
+ HomeSectionActionHeader("Recent Builds") {
+ appState.selectedTab = .builds
+ }
+ }
+ }
+
+}
+
+private struct HomeInboxToolbarIcon: View {
+ let hasUnreadThreads: Bool
+
+ var body: some View {
+ ZStack(alignment: .topTrailing) {
+ Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
+
+ if hasUnreadThreads {
+ Circle()
+ .fill(.blue)
+ .frame(width: 9, height: 9)
+ .offset(x: 4, y: -2)
+ }
+ }
+ .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
+ }
+}
+
+private struct HomeProjectRow: View {
+ let project: Project
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(project.name)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+
+ if let description = project.description, !description.isEmpty {
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ if let summary = project.resourceSummary {
+ Text(summary)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+private struct HomeProjectsListView: View {
+ let viewModel: HomeViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.projects) { project in
+ NavigationLink {
+ ProjectDetailView(project: project)
+ } label: {
+ HomeProjectRow(project: project)
+ }
+ }
+ }
+ .navigationTitle("Projects")
+ .navigationBarTitleDisplayMode(.inline)
+ .refreshable {
+ await viewModel.loadDashboard()
+ }
+ .overlay {
+ if viewModel.isLoadingProjects && viewModel.projects.isEmpty {
+ SRHTLoadingStateView(message: "Loading projects…")
+ }
+ }
+ }
+}
+
+private struct HomeBuildRow: View {
+ let build: HomeBuildItem
+
+ var body: some View {
+ HStack(spacing: 12) {
+ JobStatusIcon(status: build.job.status)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(primaryTitle)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+
+ HStack(spacing: 8) {
+ Text("Job #\(build.job.id)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Text("•")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Text(build.job.status.rawValue.capitalized)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Text("•")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Text(build.job.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Spacer()
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+
+ private var primaryTitle: String {
+ if let repositoryDisplayName = build.repositoryDisplayName {
+ return repositoryDisplayName
+ }
+ return build.job.displayLabel
+ }
+}
+
+private struct HomeAssignedTicketRow: View {
+ let ticket: HomeAssignedTicket
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ TicketStatusIcon(status: ticket.ticket.status)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(ticket.ticket.title)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+
+ Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+
+ Spacer(minLength: 8)
+
+ Text(ticket.ticket.status.displayName)
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .fixedSize()
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+private struct HomeSectionLoadingRow: View {
+ let label: String
+
+ var body: some View {
+ HStack(spacing: 10) {
+ ProgressView()
+ .controlSize(.small)
+ Text(label)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+private struct HomeSectionHeader<Destination: View>: View {
+ let title: String
+ let destination: Destination
+
+ init(_ title: String, @ViewBuilder destination: () -> Destination) {
+ self.title = title
+ self.destination = destination()
+ }
+
+ var body: some View {
+ HStack {
+ Text(title)
+ Spacer()
+ NavigationLink {
+ destination
+ } label: {
+ Text("See All")
+ .font(.caption.weight(.medium))
+ }
+ .buttonStyle(.plain)
+ }
+ .textCase(nil)
+ }
+}
+
+private struct HomeSectionActionHeader: View {
+ let title: String
+ let action: () -> Void
+
+ init(_ title: String, action: @escaping () -> Void) {
+ self.title = title
+ self.action = action
+ }
+
+ var body: some View {
+ HStack {
+ Text(title)
+ Spacer()
+ Button("See All", action: action)
+ .font(.caption.weight(.medium))
+ .buttonStyle(.plain)
+ }
+ .textCase(nil)
+ }
+}
+
+private struct HomeAssignedTicketsListView: View {
+ let viewModel: HomeViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.assignedTickets) { ticket in
+ NavigationLink {
+ TicketDetailView(
+ ownerUsername: ticket.ownerUsername,
+ trackerName: ticket.trackerName,
+ trackerId: ticket.trackerId,
+ trackerRid: ticket.trackerRid,
+ ticketId: ticket.ticket.id
+ )
+ } label: {
+ HomeAssignedTicketRow(ticket: ticket)
+ }
+ }
+
+ if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
+ HomeSectionMessageRow(
+ text: "No open tickets assigned to you.",
+ systemImage: "person.crop.circle.badge.checkmark"
+ )
+ }
+ }
+ .navigationTitle("Assigned Tickets")
+ .navigationBarTitleDisplayMode(.inline)
+ .refreshable {
+ await viewModel.loadDashboard()
+ }
+ .overlay {
+ if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
+ SRHTLoadingStateView(message: "Loading assigned tickets…")
+ }
+ }
+ }
+}
+
+private struct HomeSectionMessageRow: View {
+ let text: String
+ let systemImage: String
+ var emphasized = false
+ var accessibilityHint: String? = nil
+
+ var body: some View {
+ Label(text, systemImage: systemImage)
+ .font(.subheadline)
+ .foregroundStyle(emphasized ? .secondary : .tertiary)
+ .accessibilityHint(accessibilityHint ?? "")
+ }
+}
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
new file mode 100644
index 0000000..31dec1b
--- /dev/null
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -0,0 +1,627 @@
+import Foundation
+
+private struct HomeJobsResponse: Decodable, Sendable {
+ let jobs: HomeJobsPage
+}
+
+private struct HomeJobsPage: Decodable, Sendable {
+ let results: [HomeJobPayload]
+}
+
+private struct HomeTrackersResponse: Decodable, Sendable {
+ let trackers: HomeTrackersPage
+}
+
+private struct HomeTrackersPage: Decodable, Sendable {
+ let results: [TrackerSummary]
+ let cursor: String?
+}
+
+private struct HomeTrackerTicketsResponse: Decodable, Sendable {
+ let user: HomeTrackerTicketsUser
+}
+
+private struct HomeTrackerTicketsUser: Decodable, Sendable {
+ let tracker: HomeTrackerTicketsTracker
+}
+
+private struct HomeTrackerTicketsTracker: Decodable, Sendable {
+ let tickets: HomeTrackerTicketsPage
+}
+
+private struct HomeTrackerTicketsPage: Decodable, Sendable {
+ let results: [HomeTicketPayload]
+}
+
+private struct HomeInboxSubscriptionsResponse: Decodable, Sendable {
+ let subscriptions: HomeInboxSubscriptionPage
+}
+
+private struct HomeInboxSubscriptionPage: Decodable, Sendable {
+ let results: [HomeInboxSubscription]
+ let cursor: String?
+}
+
+private struct HomeInboxSubscription: Decodable, Sendable {
+ let list: InboxMailingListReference?
+}
+
+private struct HomeInboxListThreadsResponse: Decodable, Sendable {
+ let list: HomeInboxMailingListThreads
+}
+
+private struct HomeInboxMailingListThreads: Decodable, Sendable {
+ let threads: HomeInboxThreadPage
+}
+
+private struct HomeInboxThreadPage: Decodable, Sendable {
+ let results: [HomeInboxThreadPayload]
+}
+
+private struct HomeInboxThreadPayload: Decodable, Sendable {
+ let updated: Date
+ let subject: String
+}
+
+private struct HomeTicketPayload: Decodable, Sendable {
+ let id: Int
+ let title: String
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let created: Date
+ let submitter: Entity
+ let labels: [TicketLabel]
+ let assignees: [Entity]
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case title = "subject"
+ case status
+ case resolution
+ case created
+ case submitter
+ case labels
+ case assignees
+ }
+
+ var ticketSummary: TicketSummary {
+ TicketSummary(
+ id: id,
+ title: title,
+ status: status,
+ resolution: resolution,
+ created: created,
+ submitter: submitter,
+ labels: labels,
+ assignees: assignees
+ )
+ }
+}
+
+struct HomeAssignedTicket: Identifiable, Hashable, Sendable {
+ let trackerId: Int
+ let trackerRid: String
+ let trackerName: String
+ let ownerCanonicalName: String
+ let ticket: TicketSummary
+
+ var id: String {
+ "\(trackerRid)#\(ticket.id)"
+ }
+
+ var ownerUsername: String {
+ if ownerCanonicalName.hasPrefix("~") {
+ return String(ownerCanonicalName.dropFirst())
+ }
+ return ownerCanonicalName
+ }
+}
+
+struct HomeBuildItem: Identifiable, Hashable, Sendable {
+ let job: JobSummary
+ let repositoryName: String?
+ let repositoryOwner: String?
+
+ var id: Int { job.id }
+
+ var repositoryDisplayName: String? {
+ guard let repositoryName else { return nil }
+ if let repositoryOwner {
+ return "\(repositoryOwner)/\(repositoryName)"
+ }
+ return repositoryName
+ }
+}
+
+@Observable
+@MainActor
+final class HomeViewModel {
+ private(set) var projects: [Project] = []
+ private(set) var failedBuilds: [HomeBuildItem] = []
+ private(set) var assignedTickets: [HomeAssignedTicket] = []
+ private(set) var recentBuilds: [HomeBuildItem] = []
+ private(set) var hasUnreadInboxThreads = false
+ private(set) var isLoadingProjects = false
+ private(set) var isLoadingFailedBuilds = false
+ private(set) var isLoadingAssignedTickets = false
+ private(set) var isLoadingRecentBuilds = false
+ private(set) var failedBuildsError: String?
+ private(set) var assignedTicketsError: String?
+ private(set) var recentBuildsError: String?
+
+ private let currentUser: User
+ private let client: SRHTClient
+ private let projectService: ProjectService
+ private let ticketFetchConcurrencyLimit = 6
+ private let inboxUnreadConcurrencyLimit = 4
+ private let inboxUnreadThreadPreviewLimit = 10
+
+ private static let jobsQuery = """
+ query jobs {
+ jobs {
+ results {
+ id
+ created
+ updated
+ status
+ note
+ tags
+ visibility
+ image
+ tasks { name status }
+ manifest
+ }
+ }
+ }
+ """
+
+ private static let trackersQuery = """
+ query trackers($cursor: Cursor) {
+ trackers(cursor: $cursor) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let trackerTicketsQuery = """
+ query tickets($owner: String!, $tracker: String!) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ tickets {
+ results {
+ id
+ subject
+ status
+ resolution
+ created
+ submitter { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ assignees { canonicalName }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ private static let inboxSubscriptionsQuery = """
+ query inboxSubscriptions($cursor: Cursor) {
+ subscriptions(cursor: $cursor) {
+ results {
+ ... on MailingListSubscription {
+ list {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let inboxListThreadsQuery = """
+ query inboxListThreads($rid: ID!) {
+ list(rid: $rid) {
+ threads {
+ results {
+ updated
+ subject
+ }
+ }
+ }
+ }
+ """
+
+ init(currentUser: User, client: SRHTClient) {
+ self.currentUser = currentUser
+ self.client = client
+ self.projectService = ProjectService(client: client)
+ }
+
+ func loadDashboard() async {
+ isLoadingProjects = true
+ isLoadingFailedBuilds = true
+ isLoadingAssignedTickets = true
+ isLoadingRecentBuilds = true
+ failedBuildsError = nil
+ assignedTicketsError = nil
+ recentBuildsError = nil
+
+ async let projectsTask = loadProjects()
+ async let jobsTask = loadRecentJobs()
+ async let assignedTicketsTask = loadAssignedTickets()
+ async let inboxUnreadTask = loadInboxUnreadState()
+
+ let projectsResult = await projectsTask
+ switch projectsResult {
+ case .success(let projects):
+ self.projects = projects
+ case .failure:
+ self.projects = []
+ }
+ isLoadingProjects = false
+
+ let recentJobsResult = await jobsTask
+
+ switch recentJobsResult {
+ case .success(let recentJobs):
+ let buildItems = Self.buildItems(from: recentJobs)
+ self.recentBuilds = buildItems
+ self.failedBuilds = Self.failedBuilds(from: buildItems)
+ self.failedBuildsError = nil
+ self.recentBuildsError = nil
+ case .failure(let error):
+ self.recentBuilds = []
+ self.failedBuilds = []
+ self.failedBuildsError = error.localizedDescription
+ self.recentBuildsError = error.localizedDescription
+ }
+ isLoadingFailedBuilds = false
+ isLoadingRecentBuilds = false
+
+ let assignedTicketsResult = await assignedTicketsTask
+
+ switch assignedTicketsResult {
+ case .success(let assignedTickets):
+ self.assignedTickets = assignedTickets
+ self.assignedTicketsError = nil
+ case .failure(let error):
+ self.assignedTickets = []
+ self.assignedTicketsError = error.localizedDescription
+ }
+ isLoadingAssignedTickets = false
+
+ hasUnreadInboxThreads = (await inboxUnreadTask) ?? false
+ }
+
+ private func loadProjects() async -> Result<[Project], Error> {
+ do {
+ return .success(try await projectService.fetchProjects())
+ } catch {
+ return .failure(error)
+ }
+ }
+
+ private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
+ do {
+ let response = try await client.execute(
+ service: .builds,
+ query: Self.jobsQuery,
+ responseType: HomeJobsResponse.self
+ )
+ return .success(response.jobs.results)
+ } catch {
+ return .failure(error)
+ }
+ }
+
+ private func loadInboxUnreadState() async -> Bool? {
+ do {
+ return try await fetchHasUnreadInboxThreads()
+ } catch {
+ return nil
+ }
+ }
+
+ private func fetchHasUnreadInboxThreads() async throws -> Bool {
+ let mailingLists = try await fetchInboxMailingLists()
+ guard !mailingLists.isEmpty else { return false }
+
+ var startIndex = mailingLists.startIndex
+ while startIndex < mailingLists.endIndex {
+ let endIndex = mailingLists.index(
+ startIndex,
+ offsetBy: inboxUnreadConcurrencyLimit,
+ limitedBy: mailingLists.endIndex
+ ) ?? mailingLists.endIndex
+ let batch = Array(mailingLists[startIndex..<endIndex])
+
+ let batchHasUnread = await withTaskGroup(of: Bool.self) { group in
+ for mailingList in batch {
+ group.addTask {
+ (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false
+ }
+ }
+
+ for await hasUnread in group {
+ if hasUnread {
+ group.cancelAll()
+ return true
+ }
+ }
+ return false
+ }
+
+ if batchHasUnread {
+ return true
+ }
+
+ startIndex = endIndex
+ }
+
+ return false
+ }
+
+ private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
+ var subscriptions: [HomeInboxSubscription] = []
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.inboxSubscriptionsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: HomeInboxSubscriptionsResponse.self
+ )
+
+ subscriptions.append(contentsOf: response.subscriptions.results)
+ guard let nextCursor = response.subscriptions.cursor else {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ var seen = Set<String>()
+ return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
+ }
+
+ private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.inboxListThreadsQuery,
+ variables: ["rid": mailingList.rid],
+ responseType: HomeInboxListThreadsResponse.self
+ )
+
+ return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in
+ let normalizedSubject = thread.subject
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
+ .lowercased()
+ let threadID = "\(mailingList.rid)#\(normalizedSubject)"
+ return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+ }
+ }
+
+ private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
+ do {
+ let trackers = try await fetchAllTrackers()
+ let tickets = try await fetchAssignedTickets(for: trackers)
+ .sorted { $0.ticket.created > $1.ticket.created }
+ return .success(tickets)
+ } catch {
+ return .failure(error)
+ }
+ }
+
+ private func fetchAllTrackers() async throws -> [TrackerSummary] {
+ var allTrackers: [TrackerSummary] = []
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .todo,
+ query: Self.trackersQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: HomeTrackersResponse.self
+ )
+
+ allTrackers.append(contentsOf: response.trackers.results)
+ guard let nextCursor = response.trackers.cursor else {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ return allTrackers
+ }
+
+ private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
+ guard !trackers.isEmpty else { return [] }
+
+ var assignedTickets: [HomeAssignedTicket] = []
+ var startIndex = trackers.startIndex
+
+ while startIndex < trackers.endIndex {
+ let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
+ let batch = Array(trackers[startIndex..<endIndex])
+
+ let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
+ for tracker in batch {
+ group.addTask {
+ try await self.fetchAssignedTickets(for: tracker)
+ }
+ }
+
+ var ticketsForBatch: [HomeAssignedTicket] = []
+ for try await tickets in group {
+ ticketsForBatch.append(contentsOf: tickets)
+ }
+ return ticketsForBatch
+ }
+
+ assignedTickets.append(contentsOf: batchTickets)
+ startIndex = endIndex
+ }
+
+ return assignedTickets
+ }
+
+ private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
+ let response = try await client.execute(
+ service: .todo,
+ query: Self.trackerTicketsQuery,
+ variables: [
+ "owner": tracker.owner.canonicalName.hasPrefix("~")
+ ? String(tracker.owner.canonicalName.dropFirst())
+ : tracker.owner.canonicalName,
+ "tracker": tracker.name
+ ],
+ responseType: HomeTrackerTicketsResponse.self
+ )
+
+ return response.user.tracker.tickets.results.compactMap { payload in
+ guard payload.status.isOpen else {
+ return nil
+ }
+ guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
+ return nil
+ }
+
+ return HomeAssignedTicket(
+ trackerId: tracker.id,
+ trackerRid: tracker.rid,
+ trackerName: tracker.name,
+ ownerCanonicalName: tracker.owner.canonicalName,
+ ticket: payload.ticketSummary
+ )
+ }
+ }
+
+ nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
+ jobs.map { job in
+ let repository = primaryRepositoryReference(in: job.manifest)
+ return HomeBuildItem(
+ job: job.jobSummary,
+ repositoryName: repository?.name,
+ repositoryOwner: repository?.ownerCanonicalName
+ )
+ }
+ }
+
+ nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] {
+ builds.filter { build in
+ switch build.job.status {
+ case .failed, .timeout:
+ true
+ default:
+ false
+ }
+ }
+ }
+
+ nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
+ failedBuilds(from: buildItems(from: jobs))
+ }
+
+ nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
+ let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
+ let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
+ if assigneeCanonical == currentCanonical {
+ return true
+ }
+
+ let assigneeUsername = normalizedUsername(entity.canonicalName)
+ let currentUsername = normalizedUsername(currentUser.username)
+ return assigneeUsername == currentUsername
+ }
+
+ nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
+ guard let manifest else { return nil }
+ let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
+ return nil
+ }
+ let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
+ guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
+ let ownerRange = Range(match.range(at: 1), in: manifest),
+ let nameRange = Range(match.range(at: 2), in: manifest) else {
+ return nil
+ }
+
+ let owner = String(manifest[ownerRange])
+ var name = String(manifest[nameRange])
+ if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
+ name.removeSubrange(suffixRange)
+ }
+ name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ guard !name.isEmpty else { return nil }
+ return (owner, name)
+ }
+
+ private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return trimmed }
+ if trimmed.hasPrefix("~") {
+ return trimmed
+ }
+ return "~\(trimmed)"
+ }
+
+ private nonisolated static func normalizedUsername(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.hasPrefix("~") {
+ return String(trimmed.dropFirst())
+ }
+ return trimmed
+ }
+}
+struct HomeJobPayload: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let status: JobStatus
+ let note: String?
+ let tags: [String]
+ let visibility: Visibility?
+ let image: String?
+ let tasks: [JobTaskSummary]
+ let manifest: String?
+
+ nonisolated var jobSummary: JobSummary {
+ JobSummary(
+ id: id,
+ created: created,
+ updated: updated,
+ status: status,
+ note: note,
+ tags: tags,
+ visibility: visibility,
+ image: image,
+ tasks: tasks
+ )
+ }
+}
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
new file mode 100644
index 0000000..304e62d
--- /dev/null
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -0,0 +1,125 @@
+import SwiftUI
+
+struct InboxView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: InboxViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading inbox…")
+ }
+ }
+ .navigationTitle("Inbox")
+ .task {
+ if viewModel == nil {
+ let vm = InboxViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadThreads()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: InboxViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.threads) { thread in
+ NavigationLink(value: thread) {
+ InboxThreadRow(thread: thread)
+ }
+ .swipeActions(edge: .leading, allowsFullSwipe: true) {
+ readStateAction(for: thread, in: viewModel)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ readStateAction(for: thread, in: viewModel)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.threads.isEmpty {
+ SRHTLoadingStateView(message: "Loading inbox…")
+ } else if let error = viewModel.error, viewModel.threads.isEmpty {
+ SRHTErrorStateView(
+ title: "Failed to load inbox",
+ message: error,
+ retryAction: { await viewModel.loadThreads() }
+ )
+ } else if viewModel.threads.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "Inbox Zero",
+ systemImage: "tray",
+ description: Text("Unread threads will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
+ await viewModel.loadThreads()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadThreads()
+ }
+ .navigationDestination(for: InboxThreadSummary.self) { thread in
+ ThreadDetailView(thread: thread) {
+ viewModel.markThreadRead(thread)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ viewModel.markThreadRead(thread)
+ }
+ } label: {
+ Label("Mark as Read", systemImage: "envelope.open")
+ }
+ .tint(.blue)
+ }
+}
+
+struct InboxThreadRow: View {
+ let thread: InboxThreadSummary
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ Circle()
+ .fill(thread.isUnread ? .blue : .clear)
+ .frame(width: 8, height: 8)
+ .padding(.top, 6)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(thread.displaySubject)
+ .font(.subheadline.weight(thread.isUnread ? .semibold : .medium))
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ if thread.containsPatch {
+ Image(systemName: "arrow.triangle.branch")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Text(thread.metadataLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+
+ Spacer(minLength: 8)
+
+ Text(thread.lastActivityAt.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary.opacity(0.7))
+ .lineLimit(1)
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
new file mode 100644
index 0000000..9c1ef45
--- /dev/null
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -0,0 +1,371 @@
+import Foundation
+import os
+
+private let inboxListLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxList")
+
+private struct InboxSubscriptionsResponse: Decodable, Sendable {
+ let subscriptions: InboxSubscriptionPage
+}
+
+private struct InboxSubscriptionPage: Decodable, Sendable {
+ let results: [InboxActivitySubscription]
+ let cursor: String?
+}
+
+private struct InboxActivitySubscription: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let list: InboxMailingListReference?
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case created
+ case list
+ }
+}
+
+private struct InboxListThreadsResponse: Decodable, Sendable {
+ let list: InboxMailingListThreads
+}
+
+private struct InboxMailingListThreads: Decodable, Sendable {
+ let threads: InboxThreadPage
+}
+
+private struct InboxThreadPage: Decodable, Sendable {
+ let results: [InboxThreadPayload]
+ let cursor: String?
+}
+
+private struct InboxThreadPayload: Decodable, Sendable {
+ let created: Date
+ let updated: Date
+ let subject: String
+ let replies: Int
+ let sender: Entity
+ let root: InboxEmailPreview
+}
+
+private struct InboxEmailPreview: Decodable, Sendable {
+ let id: Int
+ let subject: String
+ let date: Date?
+ let received: Date
+ let messageID: String
+ let body: String
+ let patch: InboxPatchPreview?
+}
+
+@Observable
+@MainActor
+final class InboxViewModel {
+ private(set) var threads: [InboxThreadSummary] = []
+ private(set) var isLoading = false
+ var error: String?
+
+ private let client: SRHTClient
+ private let listThreadFetchLimit = 10
+ private let listFetchConcurrencyLimit = 4
+
+ private static let subscriptionsQuery = """
+ query inboxSubscriptions($cursor: Cursor) {
+ subscriptions(cursor: $cursor) {
+ results {
+ ... on MailingListSubscription {
+ id
+ created
+ list {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let listThreadsQuery = """
+ query inboxListThreads($rid: ID!, $cursor: Cursor) {
+ list(rid: $rid) {
+ threads(cursor: $cursor) {
+ results {
+ created
+ updated
+ subject
+ replies
+ sender { canonicalName }
+ root {
+ id
+ subject
+ date
+ received
+ messageID
+ body
+ patch { subject }
+ }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ func loadThreads() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ let subscriptions = try await fetchSubscriptions()
+ let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
+ let fetchedThreads = try await fetchThreads(for: mailingLists)
+ threads = fetchedThreads
+ .filter(\.isUnread)
+ .sorted { lhs, rhs in
+ if lhs.lastActivityAt == rhs.lastActivityAt {
+ return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
+ }
+ return lhs.lastActivityAt > rhs.lastActivityAt
+ }
+ } catch {
+ inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)")
+ self.error = "Failed to load inbox"
+ }
+ }
+
+ func markThreadRead(_ thread: InboxThreadSummary) {
+ let viewedAt = max(Date(), thread.lastActivityAt)
+ InboxReadStateStore.markViewed(viewedAt, for: thread.id)
+ inboxListLogger.debug(
+ "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
+ )
+ threads.removeAll { $0.id == thread.id }
+ }
+
+ func markThreadUnread(_ thread: InboxThreadSummary) {
+ InboxReadStateStore.markUnread(for: thread.id)
+ inboxListLogger.debug(
+ "Inbox mark unread: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=nil"
+ )
+ updateThread(thread, isUnread: true)
+ }
+
+ func toggleThreadReadState(_ thread: InboxThreadSummary) {
+ if thread.isUnread {
+ markThreadRead(thread)
+ } else {
+ markThreadUnread(thread)
+ }
+ }
+
+ private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
+ var subscriptions: [InboxActivitySubscription] = []
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.subscriptionsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: InboxSubscriptionsResponse.self
+ )
+
+ subscriptions.append(contentsOf: response.subscriptions.results)
+ guard let nextCursor = response.subscriptions.cursor else {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ return subscriptions
+ }
+
+ private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] {
+ guard !mailingLists.isEmpty else { return [] }
+
+ var summaries: [InboxThreadSummary] = []
+ var startIndex = mailingLists.startIndex
+ var failureMessages: [String] = []
+
+ while startIndex < mailingLists.endIndex {
+ let endIndex = mailingLists.index(
+ startIndex,
+ offsetBy: listFetchConcurrencyLimit,
+ limitedBy: mailingLists.endIndex
+ ) ?? mailingLists.endIndex
+ let batch = Array(mailingLists[startIndex..<endIndex])
+
+ let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in
+ for mailingList in batch {
+ group.addTask {
+ do {
+ return (try await self.fetchThreads(for: mailingList), nil)
+ } catch {
+ return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
+ }
+ }
+ }
+
+ var batchSummaries: [InboxThreadSummary] = []
+ var batchFailures: [String] = []
+ for await result in group {
+ batchSummaries.append(contentsOf: result.0)
+ if let failure = result.1 {
+ batchFailures.append(failure)
+ }
+ }
+ return (batchSummaries, batchFailures)
+ }
+
+ summaries.append(contentsOf: batchResult.0)
+ failureMessages.append(contentsOf: batchResult.1)
+ for failure in batchResult.1 {
+ inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)")
+ }
+ startIndex = endIndex
+ }
+
+ if summaries.isEmpty, let firstFailure = failureMessages.first {
+ throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)])
+ }
+
+ return deduplicateThreads(summaries)
+ }
+
+ private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.listThreadsQuery,
+ variables: ["rid": mailingList.rid],
+ responseType: InboxListThreadsResponse.self
+ )
+
+ return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
+ let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())"
+ let lastViewedAt = InboxReadStateStore.lastViewedAt(for: groupingKey)
+ let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
+ inboxListLogger.debug(
+ "Inbox thread grouping candidate: listRID=\(mailingList.rid, privacy: .public) rootMessageID=\(thread.root.messageID, privacy: .public) rootEmailID=\(thread.root.id, privacy: .public) groupingKey=\(groupingKey, privacy: .public)"
+ )
+ inboxListLogger.debug(
+ "Inbox unread state: key=\(groupingKey, privacy: .public) latestActivityAt=\(thread.updated.ISO8601Format(), privacy: .public) lastViewedAt=\(lastViewedAt?.ISO8601Format() ?? "nil", privacy: .public) isUnread=\(isUnread, privacy: .public)"
+ )
+ return InboxThreadSummary(
+ rootEmailID: thread.root.id,
+ rootMessageID: thread.root.messageID,
+ threadRootEmailIDs: [thread.root.id],
+ threadRootMessageIDs: [thread.root.messageID],
+ listID: mailingList.id,
+ listRID: mailingList.rid,
+ listName: mailingList.name,
+ listOwner: mailingList.owner,
+ subject: thread.subject,
+ latestSender: thread.sender,
+ lastActivityAt: thread.updated,
+ messageCount: thread.replies + 1,
+ repo: Self.deriveRepositoryName(from: mailingList.name),
+ containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
+ isUnread: isUnread
+ )
+ }
+ }
+
+ private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
+ var grouped: [String: InboxThreadSummary] = [:]
+
+ for thread in threads {
+ guard let existing = grouped[thread.threadGroupingKey] else {
+ grouped[thread.threadGroupingKey] = thread
+ continue
+ }
+
+ let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
+ let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
+ let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
+ let mergedMessageCount = max(
+ existing.messageCount ?? existing.threadRootMessageIDs.count,
+ thread.messageCount ?? thread.threadRootMessageIDs.count,
+ mergedRootMessageIDs.count
+ )
+
+ grouped[thread.threadGroupingKey] = InboxThreadSummary(
+ rootEmailID: latest.rootEmailID,
+ rootMessageID: latest.rootMessageID,
+ threadRootEmailIDs: mergedRootEmailIDs,
+ threadRootMessageIDs: mergedRootMessageIDs,
+ listID: latest.listID,
+ listRID: latest.listRID,
+ listName: latest.listName,
+ listOwner: latest.listOwner,
+ subject: latest.subject,
+ latestSender: latest.latestSender,
+ lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
+ messageCount: mergedMessageCount,
+ repo: latest.repo ?? existing.repo,
+ containsPatch: latest.containsPatch || existing.containsPatch,
+ isUnread: latest.isUnread || existing.isUnread
+ )
+ }
+
+ return grouped.values.sorted { lhs, rhs in
+ if lhs.lastActivityAt == rhs.lastActivityAt {
+ return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
+ }
+ return lhs.lastActivityAt > rhs.lastActivityAt
+ }
+ }
+
+ private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
+ guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
+ let current = threads[index]
+ if !isUnread {
+ threads.remove(at: index)
+ return
+ }
+ threads[index] = InboxThreadSummary(
+ rootEmailID: current.rootEmailID,
+ rootMessageID: current.rootMessageID,
+ threadRootEmailIDs: current.threadRootEmailIDs,
+ threadRootMessageIDs: current.threadRootMessageIDs,
+ listID: current.listID,
+ listRID: current.listRID,
+ listName: current.listName,
+ listOwner: current.listOwner,
+ subject: current.subject,
+ latestSender: current.latestSender,
+ lastActivityAt: current.lastActivityAt,
+ messageCount: current.messageCount,
+ repo: current.repo,
+ containsPatch: current.containsPatch,
+ isUnread: isUnread
+ )
+ }
+
+ private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
+ var seen = Set<String>()
+ return mailingLists.filter { mailingList in
+ seen.insert(mailingList.rid).inserted
+ }
+ }
+
+ nonisolated static func deriveRepositoryName(from listName: String) -> String? {
+ let separators = ["-devel", "-patches", "-dev", ".patches"]
+ for separator in separators where listName.hasSuffix(separator) {
+ return String(listName.dropLast(separator.count))
+ }
+ return nil
+ }
+}
diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift
new file mode 100644
index 0000000..6fe835c
--- /dev/null
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -0,0 +1,374 @@
+import MessageUI
+import os
+import SwiftUI
+import UIKit
+
+private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply")
+
+struct ThreadDetailView: View {
+ let thread: InboxThreadSummary
+ let onViewed: () -> Void
+ var onMarkRead: (() -> Void)? = nil
+ var onMarkUnread: (() -> Void)? = nil
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: ThreadViewModel?
+ @State private var replySuccessMessage: String?
+ @State private var loadedThreadID: String?
+ @State private var hasMarkedCurrentThreadViewed = false
+ @State private var suppressAutoMarkViewed = false
+ @State private var isUnread: Bool
+
+ init(
+ thread: InboxThreadSummary,
+ onViewed: @escaping () -> Void,
+ onMarkRead: (() -> Void)? = nil,
+ onMarkUnread: (() -> Void)? = nil
+ ) {
+ self.thread = thread
+ self.onViewed = onViewed
+ self.onMarkRead = onMarkRead
+ self.onMarkUnread = onMarkUnread
+ self._isUnread = State(initialValue: thread.isUnread)
+ }
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading thread…")
+ }
+ }
+ .navigationTitle("Thread")
+ .navigationBarTitleDisplayMode(.inline)
+ .task(id: thread.id) {
+ guard loadedThreadID != thread.id else { return }
+ let vm = ThreadViewModel(summary: thread, client: appState.client)
+ viewModel = vm
+ loadedThreadID = thread.id
+ hasMarkedCurrentThreadViewed = false
+ suppressAutoMarkViewed = false
+ isUnread = thread.isUnread
+ await vm.loadThread()
+ }
+ .onChange(of: viewModel?.thread?.id) { _, threadID in
+ guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
+ hasMarkedCurrentThreadViewed = true
+ isUnread = false
+ onViewed()
+ }
+ .sheet(item: Binding(
+ get: { viewModel?.composeDraft },
+ set: { _ in viewModel?.dismissReply() }
+ )) { draft in
+ MailComposeView(draft: draft) { result in
+ switch result {
+ case .failed(let message):
+ inboxReplyLogger.error("Inbox reply failed for thread \(thread.debugIdentifierSummary, privacy: .public): \(message, privacy: .public)")
+ viewModel?.error = message
+ case .cancelled:
+ inboxReplyLogger.debug("Inbox reply cancelled for thread \(thread.debugIdentifierSummary, privacy: .public)")
+ case .saved:
+ inboxReplyLogger.debug("Inbox reply draft saved for thread \(thread.debugIdentifierSummary, privacy: .public)")
+ case .sent:
+ inboxReplyLogger.debug("Inbox reply handed off to Mail for thread \(thread.debugIdentifierSummary, privacy: .public)")
+ replySuccessMessage = "Reply handed off to Mail."
+ Task {
+ await viewModel?.loadThread()
+ }
+ }
+ }
+ }
+ .overlay(alignment: .top) {
+ if let replySuccessMessage {
+ Text(replySuccessMessage)
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(.thinMaterial, in: Capsule())
+ .padding(.top, 8)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
+ .animation(.easeInOut(duration: 0.2), value: replySuccessMessage)
+ .onChange(of: replySuccessMessage) { _, message in
+ guard message != nil else { return }
+ Task { @MainActor in
+ try? await Task.sleep(for: .seconds(2))
+ if self.replySuccessMessage == message {
+ self.replySuccessMessage = nil
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: ThreadViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ if let thread = viewModel.thread {
+ Section {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(thread.displaySubject)
+ .font(.headline)
+ Text(headerMetadata(thread))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+ }
+
+ if let partialWarning = viewModel.partialWarning {
+ Section {
+ Text(partialWarning)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ ForEach(thread.messages) { message in
+ InboxMessageRow(message: message)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ HStack {
+ if onMarkRead != nil || onMarkUnread != nil {
+ Button(isUnread ? "Mark Read" : "Mark Unread") {
+ suppressAutoMarkViewed = !isUnread
+ if isUnread {
+ onMarkRead?()
+ isUnread = false
+ } else {
+ onMarkUnread?()
+ isUnread = true
+ }
+ }
+ }
+
+ Button("Reply") {
+ viewModel.prepareReply()
+ }
+ }
+ }
+ }
+ .overlay {
+ if viewModel.isLoading, viewModel.thread == nil {
+ SRHTLoadingStateView(message: "Loading thread…")
+ } else if let error = viewModel.error, viewModel.thread == nil {
+ SRHTErrorStateView(
+ title: "Failed to load thread",
+ message: error,
+ retryAction: { await viewModel.loadThread() }
+ )
+ }
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadThread()
+ }
+ }
+
+ private func headerMetadata(_ thread: InboxThreadDetail) -> String {
+ var parts = [thread.listDisplayName]
+ if let messageCount = thread.messageCount, messageCount > 1 {
+ parts.append("\(messageCount) messages")
+ }
+ parts.append(thread.lastActivityAt.relativeDescription)
+ return parts.joined(separator: " • ")
+ }
+}
+
+private struct InboxMessageRow: View {
+ let message: InboxMessage
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top, spacing: 12) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(senderLine)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+ Text(message.date.formatted(date: .abbreviated, time: .shortened))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ if message.isPatch {
+ Text("Patch")
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in
+ switch block {
+ case .plainText(let text):
+ Text(text)
+ .font(.body)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ case .diff(let diff):
+ ScrollView(.horizontal) {
+ DiffView(diff: diff)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+ }
+ .padding(.vertical, 6)
+ .listRowSeparator(.visible)
+ }
+
+ private var senderLine: String {
+ if let email = message.senderEmailAddress,
+ email.caseInsensitiveCompare(message.senderDisplayName) != .orderedSame {
+ return "\(message.senderDisplayName) <\(email)>"
+ }
+ return message.senderDisplayName
+ }
+}
+
+private struct MailComposeView: UIViewControllerRepresentable {
+ let draft: MailComposeDraft
+ let onComplete: (Result) -> Void
+
+ enum Result {
+ case cancelled
+ case saved
+ case sent
+ case failed(String)
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(onComplete: onComplete)
+ }
+
+ func makeUIViewController(context: Context) -> UIViewController {
+ guard MFMailComposeViewController.canSendMail() else {
+ let controller = UINavigationController(rootViewController: MailUnavailableViewController(onDismiss: {
+ context.coordinator.onComplete(.failed("Mail is not configured on this device."))
+ }))
+ DispatchQueue.main.async {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ }
+ return controller
+ }
+
+ let controller = MFMailComposeViewController()
+ controller.mailComposeDelegate = context.coordinator
+ controller.setToRecipients(draft.recipients)
+ if !draft.ccRecipients.isEmpty {
+ controller.setCcRecipients(draft.ccRecipients)
+ }
+ if !draft.subject.isEmpty {
+ controller.setSubject(draft.subject)
+ }
+ if !draft.body.isEmpty {
+ controller.setMessageBody(draft.body, isHTML: false)
+ }
+ return controller
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+
+ final class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
+ let onComplete: (Result) -> Void
+
+ init(onComplete: @escaping (Result) -> Void) {
+ self.onComplete = onComplete
+ }
+
+ func mailComposeController(
+ _ controller: MFMailComposeViewController,
+ didFinishWith result: MFMailComposeResult,
+ error: Error?
+ ) {
+ if error != nil {
+ let message = error?.localizedDescription ?? "The reply could not be sent."
+ presentFailureAlert(on: controller, message: message)
+ onComplete(.failed(message))
+ return
+ }
+ switch result {
+ case .cancelled:
+ controller.dismiss(animated: true)
+ onComplete(.cancelled)
+ case .saved:
+ controller.dismiss(animated: true)
+ onComplete(.saved)
+ case .sent:
+ controller.dismiss(animated: true)
+ onComplete(.sent)
+ case .failed:
+ let message = "Mail could not send the reply from the configured iOS Mail account."
+ presentFailureAlert(on: controller, message: message)
+ onComplete(.failed(message))
+ @unknown default:
+ let message = "Mail returned an unknown result while sending the reply."
+ presentFailureAlert(on: controller, message: message)
+ onComplete(.failed(message))
+ }
+ }
+
+ private func presentFailureAlert(on controller: UIViewController, message: String) {
+ guard controller.presentedViewController == nil else { return }
+ let alert = UIAlertController(title: "Reply Failed", message: message, preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
+ controller.present(alert, animated: true)
+ }
+ }
+}
+
+private final class MailUnavailableViewController: UIViewController {
+ private let onDismiss: () -> Void
+
+ init(onDismiss: @escaping () -> Void) {
+ self.onDismiss = onDismiss
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .systemBackground
+ navigationItem.title = "Reply"
+ navigationItem.rightBarButtonItem = UIBarButtonItem(
+ barButtonSystemItem: .done,
+ target: self,
+ action: #selector(dismissSelf)
+ )
+
+ let label = UILabel()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ label.text = "Mail is not configured on this device."
+ label.textAlignment = .center
+ label.numberOfLines = 0
+ label.textColor = .secondaryLabel
+
+ view.addSubview(label)
+ NSLayoutConstraint.activate([
+ label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
+ label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
+ label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+ ])
+ }
+
+ @objc
+ private func dismissSelf() {
+ dismiss(animated: true)
+ onDismiss()
+ }
+}
diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift
new file mode 100644
index 0000000..c042fba
--- /dev/null
+++ b/Hutch/Views/Inbox/ThreadViewModel.swift
@@ -0,0 +1,760 @@
+import Foundation
+import os
+
+private let inboxLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Inbox")
+
+private struct InboxThreadDetailResponse: Decodable, Sendable {
+ let list: InboxThreadDetailList?
+}
+
+private struct InboxThreadDetailList: Decodable, Sendable {
+ let threads: InboxThreadPayloadPage?
+}
+
+private struct InboxThreadLookupResponse: Decodable, Sendable {
+ let list: InboxThreadLookupList?
+}
+
+private struct InboxThreadLookupList: Decodable, Sendable {
+ let message: InboxThreadLookupMessage?
+}
+
+private struct InboxThreadLookupMessage: Decodable, Sendable {
+ let thread: InboxThreadPayloadDetail?
+}
+
+private struct InboxThreadPayloadDetail: Decodable, Sendable {
+ let subject: String?
+ let updated: Date?
+ let replies: Int?
+ let sender: Entity?
+ let list: InboxMailingListReference?
+ let root: InboxThreadMessagePayload?
+ let descendants: InboxThreadMessagesPage?
+}
+
+private struct InboxThreadPayloadPage: Decodable, Sendable {
+ let results: [InboxThreadPayloadDetail]
+ let cursor: String?
+}
+
+private struct InboxThreadMessagesPage: Decodable, Sendable {
+ let results: [InboxThreadMessagePayload]?
+ let cursor: String?
+}
+
+private struct InboxThreadMessagePayload: Decodable, Sendable {
+ let id: Int?
+ let sender: Entity?
+ let received: Date?
+ let date: Date?
+ let subject: String?
+ let messageID: String?
+ let body: String?
+ let rawMessage: URL?
+ let patch: InboxPatchPreview?
+}
+
+@Observable
+@MainActor
+final class ThreadViewModel {
+ private(set) var thread: InboxThreadDetail?
+ private(set) var isLoading = false
+ var error: String?
+ var partialWarning: String?
+ var composeDraft: MailComposeDraft?
+
+ private let summary: InboxThreadSummary
+ private let client: SRHTClient
+
+ private static let threadDetailQuery = """
+ query inboxThreadDetail($rid: ID!, $cursor: Cursor, $descCursor: Cursor) {
+ list(rid: $rid) {
+ threads(cursor: $cursor) {
+ results {
+ subject
+ updated
+ replies
+ sender { canonicalName }
+ list {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ root {
+ id
+ sender { canonicalName }
+ received
+ date
+ subject
+ messageID
+ body
+ rawMessage
+ patch { subject }
+ }
+ descendants(cursor: $descCursor) {
+ results {
+ id
+ sender { canonicalName }
+ received
+ date
+ subject
+ messageID
+ body
+ rawMessage
+ patch { subject }
+ }
+ cursor
+ }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let threadByMessageIDQuery = """
+ query inboxThreadByMessageID($rid: ID!, $messageID: String!, $descCursor: Cursor) {
+ list(rid: $rid) {
+ message(messageID: $messageID) {
+ thread {
+ subject
+ updated
+ replies
+ sender { canonicalName }
+ list {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ root {
+ id
+ sender { canonicalName }
+ received
+ date
+ subject
+ messageID
+ body
+ rawMessage
+ patch { subject }
+ }
+ descendants(cursor: $descCursor) {
+ results {
+ id
+ sender { canonicalName }
+ received
+ date
+ subject
+ messageID
+ body
+ rawMessage
+ patch { subject }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ init(summary: InboxThreadSummary, client: SRHTClient) {
+ self.summary = summary
+ self.client = client
+ }
+
+ func loadThread() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ partialWarning = nil
+ defer { isLoading = false }
+
+ inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
+
+ do {
+ let threadPayloads = try await fetchThreadPayloads()
+
+ guard !threadPayloads.isEmpty else {
+ throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
+ }
+
+ let listReference = threadPayloads.lazy.compactMap(\.list).first ?? InboxMailingListReference(
+ id: summary.listID,
+ rid: summary.listRID,
+ name: summary.listName,
+ owner: summary.listOwner
+ )
+ var messagesByID: [Int: InboxMessage] = [:]
+
+ var hadPartialReplyFailure = false
+
+ for payload in threadPayloads {
+ guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
+ continue
+ }
+ messagesByID[rootMessage.id] = rootMessage
+
+ do {
+ let descendantMessages = try await fetchAllDescendantMessages(
+ initialPayload: payload,
+ candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
+ )
+ for message in descendantMessages {
+ messagesByID[message.id] = message
+ }
+ } catch {
+ hadPartialReplyFailure = true
+ inboxLogger.error(
+ "Inbox thread descendants failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)"
+ )
+ }
+ }
+
+ let messages = messagesByID.values.sorted { $0.date < $1.date }
+ guard !messages.isEmpty else {
+ throw SRHTError.graphQLErrors([GraphQLError(message: "Thread root message is unavailable.", locations: nil)])
+ }
+
+ let latestPayload = threadPayloads.max(by: { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) }) ?? threadPayloads[0]
+ thread = InboxThreadDetail(
+ id: summary.id,
+ rootEmailID: summary.rootEmailID,
+ rootMessageID: summary.rootMessageID,
+ subject: latestPayload.subject ?? summary.subject,
+ author: latestPayload.sender ?? summary.latestSender,
+ lastActivityAt: latestPayload.updated ?? summary.lastActivityAt,
+ mailto: nil,
+ listID: listReference.id,
+ listRID: listReference.rid,
+ listName: listReference.name,
+ listOwner: listReference.owner,
+ messageCount: max(messages.count, summary.messageCount ?? 0),
+ messages: messages
+ )
+ if hadPartialReplyFailure {
+ partialWarning = "Some replies could not be loaded."
+ }
+ } catch {
+ if thread == nil {
+ self.error = "Failed to load thread"
+ } else {
+ self.error = error.localizedDescription
+ }
+ inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ private func fetchThreadPayloads() async throws -> [InboxThreadPayloadDetail] {
+ var payloads: [InboxThreadPayloadDetail] = []
+ var seenRoots = Set<String>()
+
+ for rootMessageID in summary.threadRootMessageIDs {
+ guard !seenRoots.contains(rootMessageID) else { continue }
+ seenRoots.insert(rootMessageID)
+ if let payload = try await fetchThreadPayload(rootMessageID: rootMessageID) {
+ payloads.append(payload)
+ }
+ }
+
+ if payloads.isEmpty, let fallback = try await fetchThreadPayload(rootMessageID: summary.rootMessageID) {
+ payloads.append(fallback)
+ }
+
+ return payloads
+ }
+
+ private func fetchThreadPayload(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
+ if let messageMatchedThread = try await fetchThreadByMessageID(rootMessageID: rootMessageID) {
+ return messageMatchedThread
+ }
+ return try await scanThreadPages(targetRootMessageID: rootMessageID)
+ }
+
+ private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
+ let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID)
+ inboxLogger.debug(
+ "Inbox thread lookup IDs: subject=\(self.summary.subject, privacy: .public) rootEmailID=\(self.summary.rootEmailID, privacy: .public) rootMessageID=\(rootMessageID, privacy: .public) candidates=\(candidateMessageIDs.joined(separator: ", "), privacy: .public)"
+ )
+
+ var lastLookupError: Error?
+
+ for messageID in candidateMessageIDs {
+ inboxLogger.debug(
+ "Inbox thread detail lookup request: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public)"
+ )
+
+ do {
+ let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
+ client: client,
+ query: Self.threadByMessageIDQuery,
+ variables: [
+ "rid": self.summary.listRID,
+ "messageID": messageID,
+ "descCursor": nil as String?
+ ]
+ )
+
+ if let thread = response.list?.message?.thread {
+ return thread
+ }
+ } catch let error as SRHTError {
+ switch error {
+ case .graphQLErrors(let errors):
+ let combinedMessage = errors.map(\.message).joined(separator: " | ")
+ inboxLogger.error(
+ "Inbox thread message lookup failed: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public) errors=\(combinedMessage, privacy: .public)"
+ )
+ if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) {
+ lastLookupError = error
+ continue
+ }
+ throw error
+ default:
+ throw error
+ }
+ }
+ }
+
+ if let lastLookupError {
+ inboxLogger.debug(
+ "Inbox thread message lookup exhausted candidates for \(self.summary.debugIdentifierSummary, privacy: .public): \(lastLookupError.localizedDescription, privacy: .public)"
+ )
+ }
+ return nil
+ }
+
+ private func scanThreadPages(targetRootMessageID: String) async throws -> InboxThreadPayloadDetail? {
+ var threadCursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = ["rid": summary.listRID]
+ if let threadCursor {
+ variables["cursor"] = threadCursor
+ }
+
+ let response: InboxThreadDetailResponse
+ do {
+ response = try await Self.executeGraphQLRequest(
+ client: client,
+ query: Self.threadDetailQuery,
+ variables: {
+ var variables = variables
+ variables["descCursor"] = nil as String?
+ return variables
+ }()
+ )
+ } catch {
+ if Self.isRecoverableNoRows(error) {
+ inboxLogger.error("Inbox thread page scan recoverable miss for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
+ return nil
+ }
+ throw error
+ }
+
+ guard let threadPage = response.list?.threads else {
+ return nil
+ }
+
+ let candidates = threadPage.results.map { payload in
+ "subject=\(payload.subject ?? "<nil>") rootEmailID=\(payload.root?.id.map(String.init) ?? "<nil>") rootMessageID=\(payload.root?.messageID ?? "<nil>")"
+ }.joined(separator: " | ")
+ inboxLogger.debug("Inbox thread detail page candidates: \(candidates, privacy: .public)")
+
+ if let matchedThread = threadPage.results.first(where: {
+ $0.root?.messageID == targetRootMessageID ||
+ $0.root?.id == summary.rootEmailID ||
+ $0.root?.subject == summary.subject
+ }) {
+ return matchedThread
+ }
+
+ guard let nextCursor = threadPage.cursor else {
+ return nil
+ }
+ threadCursor = nextCursor
+ }
+ }
+
+ private func fetchAllDescendantMessages(
+ initialPayload: InboxThreadPayloadDetail,
+ candidateMessageIDs: [String]
+ ) async throws -> [InboxMessage] {
+ var messagesByID: [Int: InboxMessage] = [:]
+
+ for payload in initialPayload.descendants?.results ?? [] {
+ if let message = Self.message(from: payload, fallbackID: nil) {
+ messagesByID[message.id] = message
+ }
+ }
+
+ var descendantCursor = initialPayload.descendants?.cursor
+ while let currentCursor = descendantCursor {
+ guard let page = try await fetchDescendantPage(
+ cursor: currentCursor,
+ candidateMessageIDs: candidateMessageIDs
+ ) else {
+ break
+ }
+
+ for payload in page.results ?? [] {
+ if let message = Self.message(from: payload, fallbackID: nil) {
+ messagesByID[message.id] = message
+ }
+ }
+ descendantCursor = page.cursor
+ }
+
+ return messagesByID.values.sorted { $0.date < $1.date }
+ }
+
+ private func fetchDescendantPage(
+ cursor: String,
+ candidateMessageIDs: [String]
+ ) async throws -> InboxThreadMessagesPage? {
+ for messageID in candidateMessageIDs {
+ let response: InboxThreadLookupResponse
+ do {
+ response = try await Self.executeGraphQLRequest(
+ client: client,
+ query: Self.threadByMessageIDQuery,
+ variables: [
+ "rid": summary.listRID,
+ "messageID": messageID,
+ "descCursor": cursor
+ ]
+ )
+ } catch {
+ if Self.isRecoverableNoRows(error) {
+ inboxLogger.error(
+ "Inbox descendant page recoverable miss: thread=\(self.summary.debugIdentifierSummary, privacy: .public) messageID=\(messageID, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
+ )
+ continue
+ }
+ throw error
+ }
+
+ if let descendants = response.list?.message?.thread?.descendants {
+ return descendants
+ }
+ }
+
+ return nil
+ }
+
+ func prepareReply() {
+ guard let thread else {
+ error = "This thread is not ready to reply to yet."
+ return
+ }
+ inboxLogger.debug(
+ "Preparing inbox reply: subject=\(thread.subject, privacy: .public) listRID=\(thread.listRID, privacy: .public) rootMessageID=\(thread.rootMessageID, privacy: .public) recipient=\(thread.replyRecipient, privacy: .public) senderIdentity=system-mail-account"
+ )
+ composeDraft = MailComposeDraft(
+ recipients: [thread.replyRecipient],
+ ccRecipients: [],
+ subject: thread.replySubject,
+ body: ""
+ )
+ }
+
+ func dismissReply() {
+ composeDraft = nil
+ }
+
+ private static func message(from payload: InboxThreadMessagePayload?, fallbackID: Int?) -> InboxMessage? {
+ guard let payload else { return nil }
+ guard let id = payload.id ?? fallbackID,
+ let author = payload.sender,
+ let date = payload.date ?? payload.received,
+ let subject = payload.subject,
+ let body = payload.body else {
+ return nil
+ }
+
+ let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author)
+ let displayBody = sanitizedDisplayBody(from: body)
+ let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil)
+
+ return InboxMessage(
+ id: id,
+ author: author,
+ date: date,
+ subject: subject,
+ body: body,
+ senderDisplayName: normalizedIdentity.displayName,
+ senderEmailAddress: normalizedIdentity.emailAddress,
+ isPatch: payload.patch != nil,
+ contentBlocks: contentBlocks,
+ rawMessageURL: payload.rawMessage
+ )
+ }
+
+ nonisolated static func mailComposeDraft(from mailto: String) -> MailComposeDraft? {
+ guard let components = URLComponents(string: mailto),
+ components.scheme?.lowercased() == "mailto" else {
+ return nil
+ }
+
+ let recipients = components.path
+ .split(separator: ",")
+ .map { String($0) }
+ .filter { !$0.isEmpty }
+ let queryItems = components.queryItems ?? []
+ let ccRecipients = queryItems
+ .first(where: { $0.name.caseInsensitiveCompare("cc") == .orderedSame })?
+ .value?
+ .split(separator: ",")
+ .map(String.init) ?? []
+ let subject = queryItems
+ .first(where: { $0.name.caseInsensitiveCompare("subject") == .orderedSame })?
+ .value ?? ""
+ let body = queryItems
+ .first(where: { $0.name.caseInsensitiveCompare("body") == .orderedSame })?
+ .value ?? ""
+
+ return MailComposeDraft(
+ recipients: recipients,
+ ccRecipients: ccRecipients,
+ subject: subject,
+ body: body
+ )
+ }
+
+ private static func messageIDCandidates(from messageID: String) -> [String] {
+ let trimmedMessageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedMessageID.isEmpty else { return [] }
+
+ if trimmedMessageID.hasPrefix("<"), trimmedMessageID.hasSuffix(">") {
+ return [trimmedMessageID, String(trimmedMessageID.dropFirst().dropLast())]
+ }
+
+ return [trimmedMessageID, "<\(trimmedMessageID)>"]
+ }
+
+ private static func normalizedSenderIdentity(from body: String, fallbackAuthor: Entity) -> (displayName: String, emailAddress: String?) {
+ guard let fromLine = leadingHeaderValue(named: "From", in: body) else {
+ return fallbackSenderIdentity(from: fallbackAuthor)
+ }
+
+ let trimmedFromLine = fromLine.trimmingCharacters(in: .whitespacesAndNewlines)
+ if let start = trimmedFromLine.lastIndex(of: "<"),
+ let end = trimmedFromLine.lastIndex(of: ">"),
+ start < end {
+ let email = String(trimmedFromLine[trimmedFromLine.index(after: start)..<end]).trimmingCharacters(in: .whitespaces)
+ let name = String(trimmedFromLine[..<start]).trimmingCharacters(in: .whitespacesAndNewlines)
+ if !name.isEmpty {
+ return (name, email.isEmpty ? nil : email)
+ }
+ return (email.isEmpty ? trimmedFromLine : email, email.isEmpty ? nil : email)
+ }
+
+ if trimmedFromLine.contains("@") {
+ return (trimmedFromLine, trimmedFromLine)
+ }
+
+ return (trimmedFromLine, nil)
+ }
+
+ private static func fallbackSenderIdentity(from author: Entity) -> (displayName: String, emailAddress: String?) {
+ let canonicalName = author.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
+ if canonicalName.contains("@") {
+ return (canonicalName, canonicalName)
+ }
+ if canonicalName.hasPrefix("~") {
+ return (String(canonicalName.dropFirst()), nil)
+ }
+ return (canonicalName, nil)
+ }
+
+ private static func sanitizedDisplayBody(from body: String) -> String {
+ let normalizedBody = normalizeLineEndings(in: body)
+ let lines = normalizedBody.components(separatedBy: "\n")
+ let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
+ var headerCount = 0
+ var blankLineIndex: Int?
+
+ for (index, line) in lines.prefix(12).enumerated() {
+ if line.isEmpty {
+ blankLineIndex = index
+ break
+ }
+ if headerPrefixes.contains(where: { line.hasPrefix($0) }) {
+ headerCount += 1
+ } else if headerCount > 0 {
+ break
+ }
+ }
+
+ guard headerCount >= 2, let blankLineIndex else {
+ return stripLeadingFromLineIfPresent(in: normalizedBody)
+ }
+
+ return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
+ }
+
+ nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
+ segmentMessageBody(body, isPatch: isPatch)
+ }
+
+ private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
+ guard isPatch else {
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
+ }
+
+ let normalizedBody = normalizeLineEndings(in: body)
+ let lines = normalizedBody.components(separatedBy: "\n")
+ guard let diffStartIndex = actualDiffStartIndex(in: lines) else {
+ let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
+ }
+
+ var blocks: [InboxMessageContentBlock] = []
+ let leadingPlainText = lines[..<diffStartIndex]
+ .joined(separator: "\n")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if !leadingPlainText.isEmpty {
+ blocks.append(.plainText(leadingPlainText))
+ }
+
+ let remainingLines = Array(lines[diffStartIndex...])
+ let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
+
+ let diffLines: ArraySlice<String>
+ let trailingPlainText: String
+ if let signatureIndex {
+ diffLines = remainingLines[..<signatureIndex]
+ trailingPlainText = remainingLines[signatureIndex...]
+ .joined(separator: "\n")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ } else {
+ diffLines = remainingLines[...]
+ trailingPlainText = ""
+ }
+
+ let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+ if !diff.isEmpty {
+ blocks.append(.diff(diff))
+ }
+
+ if !trailingPlainText.isEmpty {
+ blocks.append(.plainText(trailingPlainText))
+ }
+ return blocks
+ }
+
+ private nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? {
+ if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) {
+ return explicitDiffIndex
+ }
+
+ for index in lines.indices {
+ let line = lines[index]
+ guard line.hasPrefix("--- ") else { continue }
+ let nextIndex = lines.index(after: index)
+ guard nextIndex < lines.endIndex else { continue }
+ let nextLine = lines[nextIndex]
+ guard nextLine.hasPrefix("+++ ") else { continue }
+
+ let oldPath = String(line.dropFirst(4))
+ let newPath = String(nextLine.dropFirst(4))
+ let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") &&
+ (newPath.hasPrefix("b/") || newPath == "/dev/null")
+
+ if looksLikeUnifiedDiff {
+ return index
+ }
+ }
+
+ return nil
+ }
+
+ private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool {
+ line == "-- " || line == "--"
+ }
+
+ private nonisolated static func normalizeLineEndings(in text: String) -> String {
+ text
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ }
+
+ private static func stripLeadingFromLineIfPresent(in body: String) -> String {
+ let lines = body.components(separatedBy: "\n")
+ guard let firstLine = lines.first, firstLine.hasPrefix("From:") else {
+ return body
+ }
+
+ var remainingLines = Array(lines.dropFirst())
+ if let nextLine = remainingLines.first, nextLine.isEmpty {
+ remainingLines.removeFirst()
+ }
+ return remainingLines.joined(separator: "\n")
+ }
+
+ private static func leadingHeaderValue(named headerName: String, in body: String) -> String? {
+ let prefix = "\(headerName):"
+ let lines = body.components(separatedBy: .newlines)
+ for line in lines.prefix(12) {
+ if line.isEmpty {
+ break
+ }
+ if line.hasPrefix(prefix) {
+ return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
+ }
+ }
+ return nil
+ }
+
+ private static func executeGraphQLRequest<T: Decodable>(
+ client: SRHTClient,
+ query: String,
+ variables: [String: any Sendable]
+ ) async throws -> T {
+ guard let token = KeychainHelper.loadToken(), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ var request = URLRequest(url: SRHTService.lists.url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let encoder = JSONEncoder()
+ request.httpBody = try encoder.encode(
+ GraphQLRequestBody(
+ query: query,
+ variables: variables.mapValues { AnyCodable($0) }
+ )
+ )
+
+ let (data, _) = try await URLSession.shared.data(for: request)
+ #if DEBUG
+ let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
+ inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)")
+ #endif
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ let envelope = try decoder.decode(GraphQLResponse<T>.self, from: data)
+ if let errors = envelope.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+ guard let payload = envelope.data else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in thread detail response"))
+ )
+ }
+ return payload
+ }
+
+ private static func isRecoverableNoRows(_ error: Error) -> Bool {
+ guard case let SRHTError.graphQLErrors(errors) = error else {
+ return false
+ }
+ return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") }
+ }
+}
diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift
new file mode 100644
index 0000000..80d4e50
--- /dev/null
+++ b/Hutch/Views/Lists/MailingListListView.swift
@@ -0,0 +1,159 @@
+import SwiftUI
+
+@Observable
+@MainActor
+final class MailingListListViewModel {
+ private(set) var mailingLists: [InboxMailingListReference] = []
+ private(set) var isLoading = false
+ var error: String?
+
+ private let client: SRHTClient
+
+ private static let subscriptionsQuery = """
+ query mailingLists($cursor: Cursor) {
+ subscriptions(cursor: $cursor) {
+ results {
+ ... on MailingListSubscription {
+ list {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ }
+ }
+ cursor
+ }
+ }
+ """
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ func loadMailingLists() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ mailingLists = try await fetchMailingLists()
+ } catch {
+ self.error = "Failed to load mailing lists"
+ }
+ }
+
+ private func fetchMailingLists() async throws -> [InboxMailingListReference] {
+ struct Response: Decodable, Sendable {
+ let subscriptions: Page
+ }
+
+ struct Page: Decodable, Sendable {
+ let results: [Subscription]
+ let cursor: String?
+ }
+
+ struct Subscription: Decodable, Sendable {
+ let list: InboxMailingListReference?
+ }
+
+ var results: [InboxMailingListReference] = []
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.subscriptionsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: Response.self
+ )
+
+ results.append(contentsOf: response.subscriptions.results.compactMap(\.list))
+ guard let nextCursor = response.subscriptions.cursor else {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ var seen = Set<String>()
+ return results
+ .filter { seen.insert($0.rid).inserted }
+ .sorted {
+ if $0.owner.canonicalName == $1.owner.canonicalName {
+ return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
+ }
+ return $0.owner.canonicalName.localizedCaseInsensitiveCompare($1.owner.canonicalName) == .orderedAscending
+ }
+ }
+}
+
+struct MailingListListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: MailingListListViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading mailing lists…")
+ }
+ }
+ .navigationTitle("Lists")
+ .task {
+ if viewModel == nil {
+ let vm = MailingListListViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadMailingLists()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: MailingListListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.mailingLists, id: \.rid) { mailingList in
+ NavigationLink(value: MoreRoute.mailingList(mailingList)) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(mailingList.name)
+ .font(.subheadline.weight(.medium))
+ Text(mailingList.owner.canonicalName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 2)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.mailingLists.isEmpty {
+ SRHTLoadingStateView(message: "Loading mailing lists…")
+ } else if let error = viewModel.error, viewModel.mailingLists.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Mailing Lists",
+ message: error,
+ retryAction: { await viewModel.loadMailingLists() }
+ )
+ } else if viewModel.mailingLists.isEmpty {
+ ContentUnavailableView(
+ "No Mailing Lists",
+ systemImage: "list.bullet.rectangle",
+ description: Text("Your subscribed mailing lists will appear here.")
+ )
+ }
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadMailingLists()
+ }
+ }
+}
diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
new file mode 100644
index 0000000..b7c5576
--- /dev/null
+++ b/Hutch/Views/More/MoreView.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+struct MoreView: View {
+ private let unsupportedLinks: [(title: String, url: URL)] = [
+ ("chat.sr.ht", URL(string: "https://chat.sr.ht")!),
+ ("man.sr.ht", URL(string: "https://man.sr.ht")!),
+ ("srht.site", URL(string: "https://srht.site")!)
+ ]
+
+ var body: some View {
+ List {
+ Section {
+ NavigationLink(value: MoreRoute.lists) {
+ Label("Lists", systemImage: "list.bullet.rectangle")
+ }
+
+ NavigationLink(value: MoreRoute.pastes) {
+ Label("Pastes", systemImage: "doc.on.clipboard")
+ }
+
+ NavigationLink(value: MoreRoute.settings) {
+ Label("Settings", systemImage: "gear")
+ }
+ }
+
+ Section {
+ ForEach(unsupportedLinks, id: \.title) { item in
+ Link(destination: item.url) {
+ Label(item.title, systemImage: "safari")
+ }
+ }
+ } header: {
+ Text("External Links")
+ } footer: {
+ Text("These SourceHut services are not supported in-app and open in your browser.")
+ }
+ }
+ .navigationTitle("More")
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteDetailView.swift b/Hutch/Views/Pastes/PasteDetailView.swift
new file mode 100644
index 0000000..df3a8be
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteDetailView.swift
@@ -0,0 +1,288 @@
+import SwiftUI
+
+struct PasteDetailView: View {
+ let paste: Paste
+ var onUpdated: ((Paste) -> Void)? = nil
+ var onDeleted: ((String) -> Void)? = nil
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: PasteDetailViewModel?
+ @State private var showVisibilitySheet = false
+ @State private var showDeleteConfirmation = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading paste…")
+ }
+ }
+ .navigationTitle(displayTitle)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(
+ url: currentPaste.flatMap { SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) },
+ target: .paste
+ ) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ if viewModel != nil {
+ Menu {
+ Button {
+ showVisibilitySheet = true
+ } label: {
+ Label("Change Visibility", systemImage: "eye")
+ }
+
+ Button(role: .destructive) {
+ showDeleteConfirmation = true
+ } label: {
+ Label("Delete Paste", systemImage: "trash")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showVisibilitySheet) {
+ if let viewModel, let currentPaste {
+ PasteVisibilitySheet(
+ currentVisibility: currentPaste.visibility,
+ isUpdating: viewModel.isUpdatingVisibility
+ ) { visibility in
+ if let updated = await viewModel.updateVisibility(visibility) {
+ onUpdated?(updated)
+ showVisibilitySheet = false
+ }
+ }
+ }
+ }
+ .alert("Delete Paste?", isPresented: $showDeleteConfirmation) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ Task {
+ if await viewModel?.deletePaste() == true {
+ onDeleted?(paste.id)
+ dismiss()
+ }
+ }
+ }
+ } message: {
+ Text("This paste will be permanently removed.")
+ }
+ .task {
+ if viewModel == nil {
+ let vm = PasteDetailViewModel(
+ pasteID: paste.id,
+ initialPaste: paste,
+ service: PasteService(client: appState.client)
+ )
+ viewModel = vm
+ await vm.loadPaste()
+ }
+ }
+ }
+
+ private var currentPaste: Paste? {
+ viewModel?.paste ?? paste
+ }
+
+ private var displayTitle: String {
+ if let filename = currentPaste?.files.first?.filename, !filename.isEmpty {
+ return filename
+ }
+ return "Paste \(paste.id)"
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: PasteDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ if viewModel.isLoading, viewModel.paste == nil {
+ SRHTLoadingStateView(message: "Loading paste…")
+ } else if let error = viewModel.error, viewModel.paste == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Paste",
+ message: error,
+ retryAction: { await viewModel.loadPaste() }
+ )
+ } else if let paste = viewModel.paste {
+ List {
+ Section("Details") {
+ LabeledContent("ID", value: paste.id)
+ LabeledContent("Owner", value: paste.user.canonicalName)
+ LabeledContent("Created", value: paste.created.relativeDescription)
+ LabeledContent("Visibility", value: visibilityLabel(paste.visibility))
+ LabeledContent("Files", value: "\(paste.files.count)")
+ }
+
+ if paste.files.count > 1 {
+ Section("Files") {
+ Picker("Selected File", selection: Binding(
+ get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
+ set: { viewModel.selectFile(hash: $0) }
+ )) {
+ ForEach(paste.files) { file in
+ Text(file.filename ?? String(file.hash.prefix(8)))
+ .tag(file.hash)
+ }
+ }
+ }
+ }
+
+ if let file = viewModel.selectedFile {
+ Section("Current File") {
+ if let filename = file.filename, !filename.isEmpty {
+ LabeledContent("Filename", value: filename)
+ }
+ LabeledContent("Hash", value: file.hash)
+ }
+
+ Section {
+ if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
+ SRHTLoadingStateView(message: "Loading paste contents…")
+ .frame(minHeight: 180)
+ } else if let contents = viewModel.selectedFileContents {
+ PasteCodeBlock(text: contents)
+ } else {
+ Text("This file’s contents are unavailable.")
+ .foregroundStyle(.secondary)
+ }
+ } header: {
+ Text("Contents")
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadPaste()
+ }
+ }
+ }
+
+ private func visibilityLabel(_ visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ return "Public"
+ case .unlisted:
+ return "Unlisted"
+ case .private:
+ return "Private"
+ }
+ }
+}
+
+private struct PasteCodeBlock: View {
+ let text: String
+
+ var body: some View {
+ ScrollView([.horizontal, .vertical], showsIndicators: true) {
+ Text(text.isEmpty ? " " : text)
+ .font(.system(.body, design: .monospaced))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 4)
+ }
+ .frame(minHeight: 220)
+ }
+}
+
+private struct PasteVisibilitySheet: View {
+ let currentVisibility: Visibility
+ let isUpdating: Bool
+ let onSave: (Visibility) async -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var visibility: Visibility
+
+ init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) {
+ self.currentVisibility = currentVisibility
+ self.isUpdating = isUpdating
+ self.onSave = onSave
+ _visibility = State(initialValue: currentVisibility)
+ }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ ForEach(visibilityOptions, id: \.self) { option in
+ Button {
+ visibility = option
+ } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title(for: option))
+ .foregroundStyle(.primary)
+ Text(description(for: option))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ if visibility == option {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.tint)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Visibility")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ Task {
+ await onSave(visibility)
+ }
+ }
+ .disabled(isUpdating || visibility == currentVisibility)
+ }
+ }
+ .overlay {
+ if isUpdating {
+ ProgressView()
+ }
+ }
+ }
+ }
+
+ private var visibilityOptions: [Visibility] {
+ [.public, .unlisted, .private]
+ }
+
+ private func title(for visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ "Public"
+ case .unlisted:
+ "Unlisted"
+ case .private:
+ "Private"
+ }
+ }
+
+ private func description(for visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ "Visible to everyone and listed on your profile."
+ case .unlisted:
+ "Visible to anyone with the URL, but not listed on your profile."
+ case .private:
+ "Visible only to explicitly allowed viewers."
+ }
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteDetailViewModel.swift b/Hutch/Views/Pastes/PasteDetailViewModel.swift
new file mode 100644
index 0000000..8c60edd
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteDetailViewModel.swift
@@ -0,0 +1,114 @@
+import Foundation
+
+@Observable
+@MainActor
+final class PasteDetailViewModel {
+ private(set) var paste: Paste?
+ private(set) var isLoading = false
+ private(set) var isUpdatingVisibility = false
+ private(set) var isDeleting = false
+ private(set) var loadingFileHashes: Set<String> = []
+ var error: String?
+
+ var selectedFileHash: String?
+ private(set) var fileContents: [String: String] = [:]
+
+ private let pasteID: String
+ private let service: PasteService
+
+ init(pasteID: String, initialPaste: Paste? = nil, service: PasteService) {
+ self.pasteID = pasteID
+ self.paste = initialPaste
+ self.service = service
+ self.selectedFileHash = initialPaste?.files.first?.hash
+ }
+
+ var selectedFile: PasteFile? {
+ let hash = selectedFileHash ?? paste?.files.first?.hash
+ return paste?.files.first(where: { $0.hash == hash })
+ }
+
+ var selectedFileContents: String? {
+ guard let selectedFile else { return nil }
+ return fileContents[selectedFile.hash]
+ }
+
+ func loadPaste() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ let loaded = try await service.loadPaste(id: pasteID)
+ paste = loaded
+ if selectedFileHash == nil {
+ selectedFileHash = loaded?.files.first?.hash
+ }
+ await loadSelectedFileContentsIfNeeded()
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func selectFile(hash: String) {
+ selectedFileHash = hash
+ Task {
+ await loadSelectedFileContentsIfNeeded()
+ }
+ }
+
+ func updateVisibility(_ visibility: Visibility) async -> Paste? {
+ guard !isUpdatingVisibility else { return nil }
+ guard let paste else { return nil }
+ guard paste.visibility != visibility else { return paste }
+
+ isUpdatingVisibility = true
+ error = nil
+ defer { isUpdatingVisibility = false }
+
+ do {
+ let updatedPaste = try await service.updateVisibility(id: paste.id, visibility: visibility)
+ if let updatedPaste {
+ self.paste = updatedPaste
+ if selectedFileHash == nil {
+ selectedFileHash = updatedPaste.files.first?.hash
+ }
+ }
+ return updatedPaste
+ } catch {
+ self.error = error.localizedDescription
+ return nil
+ }
+ }
+
+ func deletePaste() async -> Bool {
+ guard !isDeleting else { return false }
+ isDeleting = true
+ error = nil
+ defer { isDeleting = false }
+
+ do {
+ _ = try await service.deletePaste(id: pasteID)
+ return true
+ } catch {
+ self.error = error.localizedDescription
+ return false
+ }
+ }
+
+ func loadSelectedFileContentsIfNeeded() async {
+ guard let file = selectedFile, fileContents[file.hash] == nil else { return }
+ guard let url = file.contents else { return }
+ guard !loadingFileHashes.contains(file.hash) else { return }
+
+ loadingFileHashes.insert(file.hash)
+ defer { loadingFileHashes.remove(file.hash) }
+
+ do {
+ fileContents[file.hash] = try await service.loadContents(from: url)
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift
new file mode 100644
index 0000000..9d8c0d4
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteListView.swift
@@ -0,0 +1,280 @@
+import SwiftUI
+
+struct PasteListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: PasteListViewModel?
+ @State private var showCreatePasteSheet = false
+ @State private var createdPaste: Paste?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading pastes…")
+ }
+ }
+ .navigationTitle("Pastes")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showCreatePasteSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreatePasteSheet) {
+ if let viewModel {
+ CreatePasteSheet(viewModel: viewModel) { paste in
+ showCreatePasteSheet = false
+ createdPaste = paste
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdPaste != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdPaste = nil
+ }
+ }
+ )) {
+ if let createdPaste {
+ PasteDetailView(
+ paste: createdPaste,
+ onUpdated: { updated in
+ viewModel?.upsertPaste(updated)
+ },
+ onDeleted: { id in
+ viewModel?.removePaste(id: id)
+ }
+ )
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = PasteListViewModel(service: PasteService(client: appState.client))
+ viewModel = vm
+ await vm.loadPastes()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: PasteListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.pastes) { paste in
+ NavigationLink(value: paste) {
+ PasteRowView(paste: paste)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: paste)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.pastes.isEmpty {
+ SRHTLoadingStateView(message: "Loading pastes…")
+ } else if let error = viewModel.error, viewModel.pastes.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Pastes",
+ message: error,
+ retryAction: { await viewModel.loadPastes() }
+ )
+ } else if viewModel.pastes.isEmpty {
+ ContentUnavailableView(
+ "No Pastes",
+ systemImage: "doc.on.clipboard",
+ description: Text("Your pastes will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) {
+ await viewModel.loadPastes()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadPastes()
+ }
+ .navigationDestination(for: Paste.self) { paste in
+ PasteDetailView(
+ paste: paste,
+ onUpdated: { updated in
+ viewModel.upsertPaste(updated)
+ },
+ onDeleted: { id in
+ viewModel.removePaste(id: id)
+ }
+ )
+ }
+ }
+}
+
+private struct PasteRowView: View {
+ let paste: Paste
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: "doc.text")
+ .foregroundStyle(.secondary)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(primaryTitle)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+
+ Text(secondaryLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ VisibilityBadge(visibility: paste.visibility)
+ Text("•")
+ .foregroundStyle(.tertiary)
+ Text(paste.created.relativeDescription)
+ .foregroundStyle(.tertiary)
+ }
+ .font(.caption2)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+
+ private var primaryTitle: String {
+ if let filename = paste.files.first?.filename, !filename.isEmpty {
+ return filename
+ }
+ return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste"
+ }
+
+ private var secondaryLine: String {
+ var parts: [String] = [paste.user.canonicalName]
+ if paste.files.count > 1 {
+ parts.append("\(paste.files.count) files")
+ } else {
+ parts.append("1 file")
+ }
+ if let firstHash = paste.files.first?.hash {
+ parts.append(String(firstHash.prefix(8)))
+ }
+ return parts.joined(separator: " • ")
+ }
+}
+
+private struct CreatePasteSheet: View {
+ let viewModel: PasteListViewModel
+ let onCreated: (Paste) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var files = [PasteUploadDraft()]
+ @State private var visibility: Visibility = .unlisted
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Files") {
+ ForEach($files) { $file in
+ VStack(alignment: .leading, spacing: 8) {
+ TextField("Filename (optional)", text: $file.filename)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ ZStack(alignment: .topLeading) {
+ if file.contents.isEmpty {
+ Text("Paste contents")
+ .foregroundStyle(.tertiary)
+ .padding(.top, 8)
+ .padding(.leading, 5)
+ .allowsHitTesting(false)
+ }
+
+ TextEditor(text: $file.contents)
+ .font(.system(.body, design: .monospaced))
+ .frame(minHeight: 180)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ .onDelete { offsets in
+ files.remove(atOffsets: offsets)
+ if files.isEmpty {
+ files = [PasteUploadDraft()]
+ }
+ }
+
+ Button {
+ files.append(PasteUploadDraft())
+ } label: {
+ Label("Add File", systemImage: "plus")
+ }
+ }
+
+ Section("Visibility") {
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ }
+
+ Section {
+ Text("Paste contents are uploaded as UTF-8 text files. Hutch can change visibility later, but the API does not support editing file contents after creation.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if let error = viewModel.error {
+ Section {
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("New Paste")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let paste = await viewModel.createPaste(files: files, visibility: visibility) {
+ onCreated(paste)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingPaste {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Paste")
+ }
+ }
+ .disabled(!hasValidContent || viewModel.isCreatingPaste)
+ }
+ }
+ }
+ }
+
+ private var hasValidContent: Bool {
+ files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteListViewModel.swift b/Hutch/Views/Pastes/PasteListViewModel.swift
new file mode 100644
index 0000000..ee7d47a
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteListViewModel.swift
@@ -0,0 +1,109 @@
+import Foundation
+
+@Observable
+@MainActor
+final class PasteListViewModel {
+ private(set) var pastes: [Paste] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isRefreshing = false
+ private(set) var isCreatingPaste = false
+ var error: String?
+
+ private var cursor: String?
+ private var hasMore = true
+ private let service: PasteService
+
+ init(service: PasteService) {
+ self.service = service
+ }
+
+ func loadPastes() async {
+ if pastes.isEmpty, let cached = service.loadCachedPastes() {
+ pastes = cached.results
+ cursor = cached.cursor
+ hasMore = cached.cursor != nil
+ }
+
+ if pastes.isEmpty {
+ isLoading = true
+ } else {
+ isRefreshing = true
+ }
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await service.listPastes(cursor: nil, useCache: true)
+ pastes = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ if pastes.isEmpty {
+ self.error = error.localizedDescription
+ }
+ }
+
+ isLoading = false
+ isRefreshing = false
+ }
+
+ func loadMoreIfNeeded(currentItem: Paste) async {
+ guard let last = pastes.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+ defer { isLoadingMore = false }
+
+ do {
+ let page = try await service.listPastes(cursor: cursor, useCache: false)
+ pastes.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func createPaste(files: [PasteUploadDraft], visibility: Visibility) async -> Paste? {
+ guard !isCreatingPaste else { return nil }
+
+ let normalizedFiles = files.filter {
+ !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+ guard !normalizedFiles.isEmpty else {
+ error = "Add at least one file with text content."
+ return nil
+ }
+
+ isCreatingPaste = true
+ error = nil
+ defer { isCreatingPaste = false }
+
+ do {
+ let paste = try await service.createPaste(files: normalizedFiles, visibility: visibility)
+ upsertPaste(paste)
+ return paste
+ } catch {
+ self.error = error.localizedDescription
+ return nil
+ }
+ }
+
+ func upsertPaste(_ paste: Paste) {
+ if let index = pastes.firstIndex(where: { $0.id == paste.id }) {
+ pastes[index] = paste
+ } else {
+ pastes.insert(paste, at: 0)
+ }
+ }
+
+ func removePaste(id: String) {
+ pastes.removeAll { $0.id == id }
+ }
+}
diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift
new file mode 100644
index 0000000..89601a9
--- /dev/null
+++ b/Hutch/Views/Projects/ProjectDetailView.swift
@@ -0,0 +1,135 @@
+import SwiftUI
+
+struct ProjectDetailView: View {
+ let project: Project
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ List {
+ headerSection
+ repositoriesSection
+ trackersSection
+ mailingListsSection
+ }
+ .navigationTitle(project.name)
+ .navigationBarTitleDisplayMode(.inline)
+ }
+
+ @ViewBuilder
+ private var headerSection: some View {
+ Section {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(project.name)
+ .font(.headline)
+
+ if let description = project.description, !description.isEmpty {
+ Text(description)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ if let website = project.website, let url = URL(string: website) {
+ Link(destination: url) {
+ Label(website, systemImage: "link")
+ .font(.subheadline)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ @ViewBuilder
+ private var repositoriesSection: some View {
+ if !project.sources.isEmpty {
+ Section("Repositories") {
+ ForEach(project.sources) { source in
+ Button {
+ Task {
+ try? await appState.openProjectSource(source)
+ dismiss()
+ }
+ } label: {
+ ProjectResourceRow(
+ title: source.name,
+ subtitle: source.owner.canonicalName,
+ detail: source.description
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var trackersSection: some View {
+ if !project.trackers.isEmpty {
+ Section("Trackers") {
+ ForEach(project.trackers) { tracker in
+ Button {
+ Task {
+ try? await appState.openProjectTracker(tracker)
+ dismiss()
+ }
+ } label: {
+ ProjectResourceRow(
+ title: tracker.name,
+ subtitle: tracker.owner.canonicalName,
+ detail: tracker.description
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var mailingListsSection: some View {
+ if !project.mailingLists.isEmpty {
+ Section("Mailing Lists") {
+ ForEach(project.mailingLists) { mailingList in
+ Button {
+ appState.openMailingList(mailingList.inboxReference)
+ dismiss()
+ } label: {
+ ProjectResourceRow(
+ title: mailingList.name,
+ subtitle: mailingList.owner.canonicalName,
+ detail: mailingList.description
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+}
+
+private struct ProjectResourceRow: View {
+ let title: String
+ let subtitle: String
+ let detail: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.subheadline.weight(.medium))
+
+ Text(subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ if let detail, !detail.isEmpty {
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .lineLimit(2)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
new file mode 100644
index 0000000..df0ffe1
--- /dev/null
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -0,0 +1,282 @@
+import SwiftUI
+
+private struct ProjectMailingListThreadsResponse: Decodable, Sendable {
+ let list: ProjectMailingListThreads
+}
+
+private struct ProjectMailingListThreads: Decodable, Sendable {
+ let threads: ProjectMailingListThreadPage
+}
+
+private struct ProjectMailingListThreadPage: Decodable, Sendable {
+ let results: [ProjectMailingListThreadPayload]
+}
+
+private struct ProjectMailingListThreadPayload: Decodable, Sendable {
+ let updated: Date
+ let subject: String
+ let replies: Int
+ let sender: Entity
+ let root: ProjectMailingListRootPayload
+}
+
+private struct ProjectMailingListRootPayload: Decodable, Sendable {
+ let id: Int
+ let messageID: String
+ let patch: InboxPatchPreview?
+}
+
+@Observable
+@MainActor
+final class MailingListDetailViewModel {
+ private(set) var threads: [InboxThreadSummary] = []
+ private(set) var isLoading = false
+ var error: String?
+
+ private let mailingList: InboxMailingListReference
+ private let client: SRHTClient
+
+ private static let listThreadsQuery = """
+ query projectMailingListThreads($rid: ID!) {
+ list(rid: $rid) {
+ threads {
+ results {
+ updated
+ subject
+ replies
+ sender { canonicalName }
+ root {
+ id
+ messageID
+ patch { subject }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ init(mailingList: InboxMailingListReference, client: SRHTClient) {
+ self.mailingList = mailingList
+ self.client = client
+ }
+
+ func loadThreads() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.listThreadsQuery,
+ variables: ["rid": mailingList.rid],
+ responseType: ProjectMailingListThreadsResponse.self
+ )
+
+ threads = deduplicateThreads(
+ response.list.threads.results.map(makeSummary(from:))
+ )
+ } catch {
+ self.error = "Failed to load mailing list"
+ }
+ }
+
+ func markThreadRead(_ thread: InboxThreadSummary) {
+ let viewedAt = max(Date(), thread.lastActivityAt)
+ InboxReadStateStore.markViewed(viewedAt, for: thread.id)
+ updateThread(thread, isUnread: false)
+ }
+
+ func markThreadUnread(_ thread: InboxThreadSummary) {
+ InboxReadStateStore.markUnread(for: thread.id)
+ updateThread(thread, isUnread: true)
+ }
+
+ private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
+ let normalizedSubject = thread.subject
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
+ .lowercased()
+ let threadID = "\(mailingList.rid)#\(normalizedSubject)"
+
+ return InboxThreadSummary(
+ rootEmailID: thread.root.id,
+ rootMessageID: thread.root.messageID,
+ threadRootEmailIDs: [thread.root.id],
+ threadRootMessageIDs: [thread.root.messageID],
+ listID: 0,
+ listRID: mailingList.rid,
+ listName: mailingList.name,
+ listOwner: mailingList.owner,
+ subject: thread.subject,
+ latestSender: thread.sender,
+ lastActivityAt: thread.updated,
+ messageCount: thread.replies + 1,
+ repo: nil,
+ containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
+ isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+ )
+ }
+
+ private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
+ guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
+ let current = threads[index]
+ threads[index] = InboxThreadSummary(
+ rootEmailID: current.rootEmailID,
+ rootMessageID: current.rootMessageID,
+ threadRootEmailIDs: current.threadRootEmailIDs,
+ threadRootMessageIDs: current.threadRootMessageIDs,
+ listID: current.listID,
+ listRID: current.listRID,
+ listName: current.listName,
+ listOwner: current.listOwner,
+ subject: current.subject,
+ latestSender: current.latestSender,
+ lastActivityAt: current.lastActivityAt,
+ messageCount: current.messageCount,
+ repo: current.repo,
+ containsPatch: current.containsPatch,
+ isUnread: isUnread
+ )
+ }
+
+ private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
+ var grouped: [String: InboxThreadSummary] = [:]
+
+ for thread in threads {
+ guard let existing = grouped[thread.threadGroupingKey] else {
+ grouped[thread.threadGroupingKey] = thread
+ continue
+ }
+
+ let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
+ let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
+ let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
+ let mergedMessageCount = max(
+ existing.messageCount ?? existing.threadRootMessageIDs.count,
+ thread.messageCount ?? thread.threadRootMessageIDs.count,
+ mergedRootMessageIDs.count
+ )
+
+ grouped[thread.threadGroupingKey] = InboxThreadSummary(
+ rootEmailID: latest.rootEmailID,
+ rootMessageID: latest.rootMessageID,
+ threadRootEmailIDs: mergedRootEmailIDs,
+ threadRootMessageIDs: mergedRootMessageIDs,
+ listID: latest.listID,
+ listRID: latest.listRID,
+ listName: latest.listName,
+ listOwner: latest.listOwner,
+ subject: latest.subject,
+ latestSender: latest.latestSender,
+ lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
+ messageCount: mergedMessageCount,
+ repo: latest.repo ?? existing.repo,
+ containsPatch: latest.containsPatch || existing.containsPatch,
+ isUnread: latest.isUnread || existing.isUnread
+ )
+ }
+
+ return grouped.values.sorted { lhs, rhs in
+ if lhs.lastActivityAt == rhs.lastActivityAt {
+ return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
+ }
+ return lhs.lastActivityAt > rhs.lastActivityAt
+ }
+ }
+}
+
+struct MailingListDetailView: View {
+ let mailingList: InboxMailingListReference
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: MailingListDetailViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading mailing list…")
+ }
+ }
+ .navigationTitle(mailingList.name)
+ .navigationBarTitleDisplayMode(.inline)
+ .task {
+ if viewModel == nil {
+ let viewModel = MailingListDetailViewModel(mailingList: mailingList, client: appState.client)
+ self.viewModel = viewModel
+ await viewModel.loadThreads()
+ }
+ }
+ .onAppear {
+ guard let viewModel else { return }
+ Task {
+ await viewModel.loadThreads()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: MailingListDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.threads) { thread in
+ NavigationLink(value: MoreRoute.thread(thread)) {
+ InboxThreadRow(thread: thread)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ if thread.isUnread {
+ viewModel.markThreadRead(thread)
+ } else {
+ viewModel.markThreadUnread(thread)
+ }
+ }
+ } label: {
+ Label(
+ thread.isUnread ? "Mark as Read" : "Mark as Unread",
+ systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
+ )
+ }
+ .tint(thread.isUnread ? .blue : .gray)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.threads.isEmpty {
+ SRHTLoadingStateView(message: "Loading mailing list…")
+ } else if let error = viewModel.error, viewModel.threads.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Mailing List",
+ message: error,
+ retryAction: { await viewModel.loadThreads() }
+ )
+ } else if viewModel.threads.isEmpty {
+ ContentUnavailableView(
+ "No Threads",
+ systemImage: "tray",
+ description: Text("This mailing list does not have any recent threads.")
+ )
+ }
+ }
+ .refreshable {
+ await viewModel.loadThreads()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ }
+}
+
+struct ProjectMailingListView: View {
+ let mailingList: Project.MailingList
+
+ var body: some View {
+ MailingListDetailView(mailingList: mailingList.inboxReference)
+ }
+}
diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift
index b1db464..4b8e512 100644
--- a/Hutch/Views/Repositories/DiffView.swift
+++ b/Hutch/Views/Repositories/DiffView.swift
@@ -9,14 +9,158 @@ struct DiffView: View {
let diff: String
var body: some View {
- let lines = diff.components(separatedBy: "\n")
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(fileSections) { section in
+ DiffFileSectionView(section: section)
+ }
+ }
+ }
+
+ private var fileSections: [DiffFileSection] {
+ DiffFileSection.parse(from: normalizedDiff)
+ }
+
+ private var normalizedDiff: String {
+ diff
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ }
+}
+
+private struct DiffFileSectionView: View {
+ let section: DiffFileSection
+ @State private var isExpanded = true
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Button {
+ isExpanded.toggle()
+ } label: {
+ HStack(spacing: 10) {
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 12)
+
+ Text(section.filename)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
- LazyVStack(alignment: .leading, spacing: 0) {
+ Spacer(minLength: 8)
+
+ Text(section.changeSummary)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .background(Color(.tertiarySystemBackground))
+
+ if isExpanded {
+ DiffBlockView(lines: section.lines)
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.06))
+ }
+ }
+}
+
+private struct DiffBlockView: View {
+ let lines: [String]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
DiffLineView(line: line)
}
}
- .font(.caption.monospaced())
+ .font(.system(.caption, design: .monospaced))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(.secondarySystemBackground))
+ }
+}
+
+private struct DiffFileSection: Identifiable {
+ let id: String
+ let filename: String
+ let lines: [String]
+ let additions: Int
+ let deletions: Int
+
+ var changeSummary: String {
+ "+\(additions) -\(deletions)"
+ }
+
+ static func parse(from diff: String) -> [DiffFileSection] {
+ let lines = diff.components(separatedBy: "\n")
+ guard !lines.isEmpty else { return [] }
+
+ let boundaries = lines.enumerated().compactMap { index, line in
+ line.hasPrefix("diff --git ") ? index : nil
+ }
+
+ guard !boundaries.isEmpty else {
+ let section = makeSection(lines: lines, fallbackIndex: 0)
+ return section.lines.isEmpty ? [] : [section]
+ }
+
+ var sections: [DiffFileSection] = []
+ for (position, startIndex) in boundaries.enumerated() {
+ let endIndex = position + 1 < boundaries.count ? boundaries[position + 1] : lines.count
+ let sectionLines = Array(lines[startIndex..<endIndex])
+ let section = makeSection(lines: sectionLines, fallbackIndex: position)
+ if !section.lines.isEmpty {
+ sections.append(section)
+ }
+ }
+ return sections
+ }
+
+ private static func makeSection(lines: [String], fallbackIndex: Int) -> DiffFileSection {
+ let filename = fileName(from: lines) ?? "File \(fallbackIndex + 1)"
+ let additions = lines.filter { $0.hasPrefix("+") && !$0.hasPrefix("+++") }.count
+ let deletions = lines.filter { $0.hasPrefix("-") && !$0.hasPrefix("---") }.count
+ return DiffFileSection(
+ id: "\(fallbackIndex)-\(filename)",
+ filename: filename,
+ lines: lines,
+ additions: additions,
+ deletions: deletions
+ )
+ }
+
+ private static func fileName(from lines: [String]) -> String? {
+ if let diffHeader = lines.first(where: { $0.hasPrefix("diff --git ") }) {
+ let parts = diffHeader.split(separator: " ")
+ if let rhs = parts.last, rhs.hasPrefix("b/") {
+ return String(rhs.dropFirst(2))
+ }
+ }
+
+ if let plusHeader = lines.first(where: { $0.hasPrefix("+++ ") }) {
+ let path = String(plusHeader.dropFirst(4))
+ if path.hasPrefix("b/") {
+ return String(path.dropFirst(2))
+ }
+ return path
+ }
+
+ if let minusHeader = lines.first(where: { $0.hasPrefix("--- ") }) {
+ let path = String(minusHeader.dropFirst(4))
+ if path.hasPrefix("a/") {
+ return String(path.dropFirst(2))
+ }
+ return path
+ }
+
+ return nil
}
}
@@ -27,7 +171,6 @@ private struct DiffLineView: View {
Text(line.isEmpty ? " " : line)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
- .padding(.vertical, 1)
.background(backgroundColor)
.foregroundStyle(foregroundColor)
.fontWeight(isHeader ? .semibold : .regular)
@@ -47,9 +190,9 @@ private struct DiffLineView: View {
switch kind {
case .added: .green.opacity(0.15)
case .removed: .red.opacity(0.15)
- case .hunk: .gray.opacity(0.12)
- case .fileHeader: .gray.opacity(0.08)
- case .meta: .gray.opacity(0.05)
+ case .hunk: .clear
+ case .fileHeader: .clear
+ case .meta: .clear
case .context: .clear
}
}
diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift
index 6fcfafa..7cac8a8 100644
--- a/Hutch/Views/Repositories/RepositoryListView.swift
+++ b/Hutch/Views/Repositories/RepositoryListView.swift
@@ -63,7 +63,10 @@ struct RepositoryListView: View {
List {
ForEach(viewModel.repositories) { repo in
NavigationLink(value: repo) {
- RepositoryRowView(repository: repo)
+ RepositoryRowView(
+ repository: repo,
+ buildStatus: viewModel.latestBuildStatus(for: repo)
+ )
}
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
.task {
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
index 6b63203..5bdc4a5 100644
--- a/Hutch/Views/Repositories/RepositoryListViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -27,6 +27,7 @@ enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
final class RepositoryListViewModel {
private(set) var repositories: [RepositorySummary] = []
+ private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:]
private(set) var isLoading = false
private(set) var isLoadingMore = false
private(set) var isRefreshing = false
@@ -41,9 +42,11 @@ final class RepositoryListViewModel {
private(set) var hasLoadedSearchIndex = false
private var searchIndex: [RepositorySummary] = []
private let client: SRHTClient
+ private var buildStatusTask: Task<Void, Never>?
private static let gitCacheKey = "git.repositories"
private static let hgCacheKey = "hg.repositories"
+ private static let buildsCacheKey = "builds.repository-status"
private static let minimumRemoteSearchLength = 3
init(client: SRHTClient) {
@@ -117,6 +120,20 @@ final class RepositoryListViewModel {
}
"""
+ private static let buildsQuery = """
+ query jobs($cursor: Cursor) {
+ jobs(cursor: $cursor) {
+ results {
+ id
+ created
+ status
+ manifest
+ }
+ cursor
+ }
+ }
+ """
+
// MARK: - Public API
/// Fetch the first page of repositories. Shows cached data instantly if available,
@@ -168,6 +185,7 @@ final class RepositoryListViewModel {
}
repositories = filteredResults.sorted(by: repositorySortOrder)
+ scheduleBuildStatusRefresh()
} catch {
// Only show error if we have no cached data to fall back on
if repositories.isEmpty {
@@ -245,6 +263,7 @@ final class RepositoryListViewModel {
}
repositories.insert(repository, at: 0)
insertIntoSearchIndex(repository)
+ scheduleBuildStatusRefresh()
return repository
} catch {
self.error = repositoryCreationErrorMessage(for: error)
@@ -306,6 +325,22 @@ final class RepositoryListViewModel {
let createRepository: HGRepositoryPayload
}
+ private struct BuildJobsResponse: Decodable, Sendable {
+ let jobs: BuildJobsPage
+ }
+
+ private struct BuildJobsPage: Decodable, Sendable {
+ let results: [BuildStatusPayload]
+ let cursor: String?
+ }
+
+ private struct BuildStatusPayload: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let status: JobStatus
+ let manifest: String?
+ }
+
private struct HGPage: Decodable, Sendable {
let results: [HGRepositoryPayload]
let cursor: String?
@@ -380,6 +415,10 @@ final class RepositoryListViewModel {
searchIndex
}
+ func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus {
+ latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none
+ }
+
private func fetchPage(
service: SRHTService,
cursor: String?,
@@ -514,9 +553,94 @@ final class RepositoryListViewModel {
let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
repositories = sortedRepositories
updateSearchIndex(with: sortedRepositories)
+ scheduleBuildStatusRefresh()
}
}
+ private func scheduleBuildStatusRefresh() {
+ let repositoriesSnapshot = repositories
+ buildStatusTask?.cancel()
+ buildStatusTask = Task { [weak self] in
+ guard let self else { return }
+ await self.loadLatestBuildStatuses(for: repositoriesSnapshot)
+ }
+ }
+
+ private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async {
+ let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:)))
+ guard !targetKeys.isEmpty else {
+ await MainActor.run {
+ latestBuildStatuses = [:]
+ }
+ return
+ }
+
+ var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:]
+ var cursor: String?
+ var shouldUseCache = true
+
+ do {
+ while !Task.isCancelled {
+ let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache)
+ shouldUseCache = false
+
+ for job in page.results {
+ let jobStatus = Self.repositoryBuildStatus(for: job.status)
+ guard let manifest = job.manifest else { continue }
+
+ for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) {
+ let existing = resolvedStatuses[key]
+ if existing == nil || existing!.0 < job.created {
+ resolvedStatuses[key] = (job.created, jobStatus)
+ }
+ }
+ }
+
+ if resolvedStatuses.count == targetKeys.count || page.cursor == nil {
+ break
+ }
+ cursor = page.cursor
+ }
+
+ let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in
+ result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none
+ }
+
+ await MainActor.run {
+ guard repositories == self.repositories else { return }
+ latestBuildStatuses = finalStatuses
+ }
+ } catch {
+ // Build status is auxiliary data for the list. Leave the default gray state on failure.
+ }
+ }
+
+ private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ if useCache && cursor == nil {
+ let result = try await client.executeAndCache(
+ service: .builds,
+ query: Self.buildsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: BuildJobsResponse.self,
+ cacheKey: Self.buildsCacheKey
+ )
+ return result.jobs
+ }
+
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.buildsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: BuildJobsResponse.self
+ )
+ return result.jobs
+ }
+
private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
var allRepositories: [RepositorySummary] = []
var currentCursor: String? = nil
@@ -580,6 +704,67 @@ final class RepositoryListViewModel {
repo.description?.lowercased().contains(lowercasedQuery) ?? false
}
}
+
+ nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String {
+ buildStatusCacheKey(
+ service: repository.service,
+ ownerCanonicalName: repository.owner.canonicalName,
+ repositoryName: repository.name
+ )
+ }
+
+ nonisolated static func buildStatusCacheKey(
+ service: SRHTService,
+ ownerCanonicalName: String,
+ repositoryName: String
+ ) -> String {
+ "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())"
+ }
+
+ nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus {
+ switch jobStatus {
+ case .success:
+ .success
+ case .pending, .queued, .running:
+ .running
+ case .failed, .cancelled, .timeout:
+ .failed
+ }
+ }
+
+ nonisolated static func buildStatusKeys(in manifest: String) -> Set<String> {
+ let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
+ return []
+ }
+
+ let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
+ return regex.matches(in: manifest, options: [], range: nsRange).reduce(into: Set<String>()) { result, match in
+ guard
+ let serviceRange = Range(match.range(at: 1), in: manifest),
+ let ownerRange = Range(match.range(at: 2), in: manifest),
+ let nameRange = Range(match.range(at: 3), in: manifest)
+ else {
+ return
+ }
+
+ let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git
+ let owner = String(manifest[ownerRange]).lowercased()
+ var name = String(manifest[nameRange]).lowercased()
+
+ if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
+ name.removeSubrange(suffixRange)
+ }
+ name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ if !name.isEmpty {
+ result.insert(buildStatusCacheKey(
+ service: service,
+ ownerCanonicalName: owner,
+ repositoryName: name
+ ))
+ }
+ }
+ }
}
private extension Array {
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
index 9f8cd15..f8fd205 100644
--- a/Hutch/Views/Repositories/RepositoryRowView.swift
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct RepositoryRowView: View {
let repository: RepositorySummary
+ let buildStatus: RepositoryBuildStatus
var body: some View {
VStack(alignment: .leading, spacing: 4) {
@@ -20,6 +21,9 @@ struct RepositoryRowView: View {
.foregroundStyle(.cyan)
}
+ if buildStatus != .none {
+ RepositoryBuildStatusIndicator(status: buildStatus)
+ }
VisibilityBadge(visibility: repository.visibility)
}
@@ -53,6 +57,47 @@ struct RepositoryRowView: View {
}
}
+private struct RepositoryBuildStatusIndicator: View {
+ let status: RepositoryBuildStatus
+
+ var body: some View {
+ Circle()
+ .fill(color)
+ .frame(width: 8, height: 8)
+ .overlay {
+ Circle()
+ .strokeBorder(.primary.opacity(0.08))
+ }
+ .accessibilityLabel(accessibilityLabel)
+ }
+
+ private var color: Color {
+ switch status {
+ case .success:
+ .green
+ case .failed:
+ .red
+ case .running:
+ .orange
+ case .none:
+ .clear
+ }
+ }
+
+ private var accessibilityLabel: String {
+ switch status {
+ case .success:
+ "Latest build succeeded"
+ case .failed:
+ "Latest build failed"
+ case .running:
+ "Latest build is running"
+ case .none:
+ "No recent builds"
+ }
+ }
+}
+
// MARK: - VisibilityBadge
struct VisibilityBadge: View {
diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift
index a2cf699..7861b40 100644
--- a/Hutch/Views/Repositories/RepositorySummarySupport.swift
+++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift
@@ -1,5 +1,12 @@
import SwiftUI
+enum RepositoryBuildStatus: Sendable {
+ case success
+ case failed
+ case running
+ case none
+}
+
struct RepositoryCloneURLs {
let readOnly: String
let readWrite: String
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 9b7d41f..91aa420 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -12,21 +12,19 @@ struct SettingsView: View {
@State private var pendingDestructiveAction: SettingsDestructiveAction?
var body: some View {
- NavigationStack {
- Group {
- if let viewModel {
- settingsContent(viewModel)
- } else {
- SRHTLoadingStateView(message: "Loading profile…")
- }
+ Group {
+ if let viewModel {
+ settingsContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading profile…")
}
- .navigationTitle("Settings")
- .task {
- if viewModel == nil {
- let vm = SettingsViewModel(client: appState.client)
- viewModel = vm
- await vm.loadProfile()
- }
+ }
+ .navigationTitle("Settings")
+ .task {
+ if viewModel == nil {
+ let vm = SettingsViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadProfile()
}
}
}
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index f553bd8..5ca8618 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -132,7 +132,7 @@ struct TicketDetailView: View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Header
- ticketHeader(ticket)
+ ticketHeader(ticket, viewModel: viewModel)
Divider()
.padding(.vertical, 12)
@@ -186,10 +186,16 @@ struct TicketDetailView: View {
// MARK: - Header
@ViewBuilder
- private func ticketHeader(_ ticket: TicketDetail) -> some View {
+ private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
VStack(alignment: .leading, spacing: 8) {
- Text(ticket.title)
- .font(.title3.weight(.semibold))
+ HStack(alignment: .top, spacing: 12) {
+ Text(ticket.title)
+ .font(.title3.weight(.semibold))
+
+ Spacer(minLength: 12)
+
+ assignToMeButton(ticket: ticket, viewModel: viewModel)
+ }
HStack(spacing: 8) {
TicketStatusIcon(status: ticket.status)
@@ -235,6 +241,43 @@ struct TicketDetailView: View {
.padding()
}
+ @ViewBuilder
+ private func assignToMeButton(ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
+ if let currentUser = appState.currentUser {
+ let isAssignedToCurrentUser = ticket.assignees.contains {
+ TicketDetailViewModel.matchesAssignee($0, user: currentUser)
+ }
+
+ if isAssignedToCurrentUser {
+ Label("Assigned to you", systemImage: "checkmark.circle.fill")
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(Color(.secondarySystemFill), in: Capsule())
+ } else {
+ Button {
+ Task {
+ await viewModel.assignToCurrentUser(currentUser)
+ }
+ } label: {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ .controlSize(.small)
+ .frame(minWidth: 88)
+ } else {
+ Text("Assign to Me")
+ .font(.caption.weight(.semibold))
+ .frame(minWidth: 88)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ .disabled(viewModel.isPerformingAction)
+ }
+ }
+ }
+
// MARK: - Comment Input
@ViewBuilder
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
index 90e33aa..660715b 100644
--- a/Hutch/Views/Tickets/TicketDetailViewModel.swift
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -433,6 +433,64 @@ final class TicketDetailViewModel {
isPerformingAction = false
}
+ func assignToCurrentUser(_ user: User) async {
+ guard !isPerformingAction, let currentTicket = ticket else { return }
+
+ let currentAssignees = currentTicket.assignees
+ let currentEntity = Entity(canonicalName: user.canonicalName)
+ guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
+ return
+ }
+
+ isPerformingAction = true
+ error = nil
+
+ ticket = TicketDetail(
+ id: currentTicket.id,
+ created: currentTicket.created,
+ updated: currentTicket.updated,
+ title: currentTicket.title,
+ description: currentTicket.description,
+ status: currentTicket.status,
+ resolution: currentTicket.resolution,
+ authenticity: currentTicket.authenticity,
+ submitter: currentTicket.submitter,
+ assignees: currentAssignees + [currentEntity],
+ labels: currentTicket.labels
+ )
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.assignUserMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "userId": user.id
+ ],
+ responseType: AssignUserResponse.self
+ )
+ await loadTicket()
+ } catch {
+ ticket = TicketDetail(
+ id: currentTicket.id,
+ created: currentTicket.created,
+ updated: currentTicket.updated,
+ title: currentTicket.title,
+ description: currentTicket.description,
+ status: currentTicket.status,
+ resolution: currentTicket.resolution,
+ authenticity: currentTicket.authenticity,
+ submitter: currentTicket.submitter,
+ assignees: currentAssignees,
+ labels: currentTicket.labels
+ )
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
func unassignUser(username: String) async {
guard !isPerformingAction else { return }
isPerformingAction = true
@@ -558,4 +616,26 @@ final class TicketDetailViewModel {
isPerformingAction = false
}
+ static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
+ let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
+ let userCanonical = normalizedCanonicalName(user.canonicalName)
+ if assigneeCanonical == userCanonical {
+ return true
+ }
+ return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
+ }
+
+ private static func normalizedCanonicalName(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.hasPrefix("~") {
+ return trimmed
+ }
+ return "~\(trimmed)"
+ }
+
+ private static func normalizedUsername(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+ }
+
}
diff --git a/HutchTests/HomeViewModelTests.swift b/HutchTests/HomeViewModelTests.swift
new file mode 100644
index 0000000..5475bdd
--- /dev/null
+++ b/HutchTests/HomeViewModelTests.swift
@@ -0,0 +1,67 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct HomeViewModelTests {
+
+ @Test
+ func failedBuildsKeepsOnlyFailedAndTimedOutJobs() {
+ let jobs = [
+ makeJob(id: 1, status: .success, created: Date(timeIntervalSince1970: 10)),
+ makeJob(id: 2, status: .failed, created: Date(timeIntervalSince1970: 20)),
+ makeJob(id: 3, status: .timeout, created: Date(timeIntervalSince1970: 30)),
+ makeJob(id: 4, status: .running, created: Date(timeIntervalSince1970: 40))
+ ]
+
+ let failedBuilds = HomeViewModel.failedBuilds(from: jobs)
+
+ #expect(failedBuilds.map(\.job.id) == [2, 3])
+ }
+
+ @Test
+ func matchesCurrentUserAssigneeNormalizesCanonicalNameAndUsername() {
+ let currentUser = User(
+ id: 42,
+ username: "owner",
+ canonicalName: "~owner",
+ email: "[email protected]",
+ avatar: nil
+ )
+
+ #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "~owner"), currentUser: currentUser))
+ #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "owner"), currentUser: currentUser))
+ #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: currentUser.username), currentUser: currentUser))
+ #expect(HomeViewModel.matchesCurrentUserAssignee(Entity(canonicalName: "~someone-else"), currentUser: currentUser) == false)
+ }
+
+ @Test
+ func primaryRepositoryReferenceParsesManifestSourceURL() {
+ let manifest = """
+ image: alpine/latest
+ sources:
+ - https://git.sr.ht/~owner/hutch
+ tasks:
+ - true
+ """
+
+ let repository = HomeViewModel.primaryRepositoryReference(in: manifest)
+
+ #expect(repository?.ownerCanonicalName == "~owner")
+ #expect(repository?.name == "hutch")
+ }
+
+ private func makeJob(id: Int, status: JobStatus, created: Date) -> HomeJobPayload {
+ HomeJobPayload(
+ id: id,
+ created: created,
+ updated: created,
+ status: status,
+ note: nil,
+ tags: [],
+ visibility: nil,
+ image: nil,
+ tasks: [],
+ manifest: nil
+ )
+ }
+}
diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift
new file mode 100644
index 0000000..b202d03
--- /dev/null
+++ b/HutchTests/InboxViewModelTests.swift
@@ -0,0 +1,168 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct InboxViewModelTests {
+
+ @Test
+ func derivesRepositoryNameFromCommonPatchListSuffixes() {
+ #expect(InboxViewModel.deriveRepositoryName(from: "hut-devel") == "hut")
+ #expect(InboxViewModel.deriveRepositoryName(from: "git.patches") == "git")
+ #expect(InboxViewModel.deriveRepositoryName(from: "discuss") == nil)
+ }
+
+ @Test
+ func parsesMailtoDraft() {
+ let draft = ThreadViewModel.mailComposeDraft(
+ from: "mailto:[email protected][email protected]&subject=Re:%20PATCH&body=LGTM"
+ )
+
+ #expect(draft?.recipients == ["[email protected]"])
+ #expect(draft?.ccRecipients == ["[email protected]"])
+ #expect(draft?.subject == "Re: PATCH")
+ #expect(draft?.body == "LGTM")
+ }
+
+ @Test
+ func computesLocalUnreadStateFromLastViewedMarker() {
+ let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let threadID = "list#message"
+ let lastActivity = Date(timeIntervalSince1970: 2_000)
+
+ #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
+
+ InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: threadID, defaults: defaults)
+ #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
+
+ InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 2_500), for: threadID, defaults: defaults)
+ #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
+ }
+
+ @Test
+ func normalizesThreadSubjectsForDisplay() {
+ let summary = InboxThreadSummary(
+ rootEmailID: 1,
+ rootMessageID: "message",
+ threadRootEmailIDs: [1],
+ threadRootMessageIDs: ["message"],
+ listID: 2,
+ listRID: "list",
+ listName: "hut-devel",
+ listOwner: Entity(canonicalName: "~owner"),
+ subject: "Re: Fwd: [PATCH] test: add parser ",
+ latestSender: Entity(canonicalName: "~sender"),
+ lastActivityAt: Date(timeIntervalSince1970: 2_000),
+ messageCount: 3,
+ repo: "hut",
+ containsPatch: true,
+ isUnread: true
+ )
+
+ #expect(summary.displaySubject == "[PATCH] test: add parser")
+ #expect(summary.metadataLine.contains("~sender"))
+ #expect(summary.metadataLine.contains("2 replies"))
+ }
+
+ @Test
+ func keepsDistinctThreadsDistinctByRootMessageID() {
+ let baseList = InboxMailingListReference(
+ id: 1,
+ rid: "list",
+ name: "hut-devel",
+ owner: Entity(canonicalName: "~owner")
+ )
+
+ let first = InboxThreadSummary(
+ rootEmailID: 10,
+ rootMessageID: "message-1",
+ threadRootEmailIDs: [10],
+ threadRootMessageIDs: ["message-1"],
+ listID: baseList.id,
+ listRID: baseList.rid,
+ listName: baseList.name,
+ listOwner: baseList.owner,
+ subject: "[PATCH] test",
+ latestSender: Entity(canonicalName: "~a"),
+ lastActivityAt: Date(timeIntervalSince1970: 100),
+ messageCount: 1,
+ repo: "hut",
+ containsPatch: true,
+ isUnread: true
+ )
+ let second = InboxThreadSummary(
+ rootEmailID: 11,
+ rootMessageID: "message-2",
+ threadRootEmailIDs: [11],
+ threadRootMessageIDs: ["message-2"],
+ listID: baseList.id,
+ listRID: baseList.rid,
+ listName: baseList.name,
+ listOwner: baseList.owner,
+ subject: "[PATCH] test",
+ latestSender: Entity(canonicalName: "~b"),
+ lastActivityAt: Date(timeIntervalSince1970: 200),
+ messageCount: 2,
+ repo: "hut",
+ containsPatch: true,
+ isUnread: true
+ )
+
+ #expect(first.id != second.id)
+ }
+
+ @Test
+ func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
+ let body = """
+ From: Christian Cleberg <[email protected]>
+
+ ---
+ test-patch.txt | 1 +
+ 1 file changed, 1 insertion(+)
+ create mode 100644 test-patch.txt
+
+ diff --git a/test-patch.txt b/test-patch.txt
+ new file mode 100644
+ index 0000000..c7b6eed
+ --- /dev/null
+ +++ b/test-patch.txt
+ @@ -0,0 +1 @@
+ +test Wed Mar 18 23:19:03 CDT 2026
+ --
+ 2.50.1 (Apple Git-155)
+ """
+
+ let segments = ThreadViewModel.segmentMessageBodyForTesting(body, isPatch: true)
+
+ #expect(segments.count == 3)
+
+ guard case let .plainText(leadingPlainText) = segments[0] else {
+ Issue.record("Expected first segment to be plain text")
+ return
+ }
+ #expect(leadingPlainText.contains("From: Christian Cleberg <[email protected]>"))
+ #expect(leadingPlainText.contains("---"))
+ #expect(leadingPlainText.contains(" test-patch.txt | 1 +"))
+ #expect(leadingPlainText.contains(" 1 file changed, 1 insertion(+)"))
+ #expect(leadingPlainText.contains(" create mode 100644 test-patch.txt"))
+
+ guard case let .diff(diff) = segments[1] else {
+ Issue.record("Expected second segment to be diff")
+ return
+ }
+ #expect(diff.contains("diff --git a/test-patch.txt b/test-patch.txt"))
+ #expect(diff.contains("--- /dev/null"))
+ #expect(diff.contains("+++ b/test-patch.txt"))
+ #expect(diff.contains("+test Wed Mar 18 23:19:03 CDT 2026"))
+ #expect(!diff.contains("-- \n2.50.1 (Apple Git-155)"))
+
+ guard case let .plainText(trailingPlainText) = segments[2] else {
+ Issue.record("Expected third segment to be plain text")
+ return
+ }
+ #expect(trailingPlainText.contains("--"))
+ #expect(trailingPlainText.contains("2.50.1 (Apple Git-155)"))
+ }
+}
diff --git a/HutchTests/ProjectTests.swift b/HutchTests/ProjectTests.swift
new file mode 100644
index 0000000..a94d6d2
--- /dev/null
+++ b/HutchTests/ProjectTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct ProjectTests {
+ @Test
+ func resourceSummaryIncludesCounts() {
+ let project = Project(
+ id: "project-1",
+ name: "Hutch",
+ description: nil,
+ website: nil,
+ visibility: .public,
+ tags: [],
+ mailingLists: [
+ Project.MailingList(
+ id: "list-1",
+ name: "hutch-devel",
+ description: nil,
+ visibility: .public,
+ owner: Entity(canonicalName: "~owner")
+ )
+ ],
+ sources: [
+ Project.SourceRepo(
+ id: "repo-1",
+ name: "hutch",
+ description: nil,
+ visibility: .public,
+ owner: Entity(canonicalName: "~owner"),
+ repoType: .git
+ ),
+ Project.SourceRepo(
+ id: "repo-2",
+ name: "hutch-web",
+ description: nil,
+ visibility: .public,
+ owner: Entity(canonicalName: "~owner"),
+ repoType: .git
+ )
+ ],
+ trackers: [
+ Project.Tracker(
+ id: "tracker-1",
+ name: "bugs",
+ description: nil,
+ visibility: .public,
+ owner: Entity(canonicalName: "~owner")
+ )
+ ]
+ )
+
+ #expect(project.resourceSummary == "2 repos • 1 tracker • 1 list")
+ }
+
+ @Test
+ func resourceSummaryFallsBackToWebsite() {
+ let project = Project(
+ id: "project-1",
+ name: "Docs",
+ description: nil,
+ website: "https://example.com",
+ visibility: .public,
+ tags: [],
+ mailingLists: [],
+ sources: [],
+ trackers: []
+ )
+
+ #expect(project.resourceSummary == "Website linked")
+ }
+}
diff --git a/HutchTests/RepositoryListViewModelTests.swift b/HutchTests/RepositoryListViewModelTests.swift
index d6e76bf..7bd9126 100644
--- a/HutchTests/RepositoryListViewModelTests.swift
+++ b/HutchTests/RepositoryListViewModelTests.swift
@@ -28,6 +28,32 @@ struct RepositoryListViewModelTests {
#expect(descriptionMatches.map(\.id) == [2])
}
+ @Test
+ func buildStatusKeysParsesSourceHutRepositoryURLsFromManifest() {
+ let manifest = """
+ image: alpine/latest
+ sources:
+ - https://git.sr.ht/~owner/hutch
+ - ssh://[email protected]/~owner/wiki
+ tasks:
+ - echo "build"
+ """
+
+ let keys = RepositoryListViewModel.buildStatusKeys(in: manifest)
+
+ #expect(keys.contains("git|~owner|hutch"))
+ #expect(keys.contains("hg|~owner|wiki"))
+ }
+
+ @Test
+ func repositoryBuildStatusMapsJobStatesToRowStates() {
+ #expect(RepositoryListViewModel.repositoryBuildStatus(for: .success) == .success)
+ #expect(RepositoryListViewModel.repositoryBuildStatus(for: .running) == .running)
+ #expect(RepositoryListViewModel.repositoryBuildStatus(for: .queued) == .running)
+ #expect(RepositoryListViewModel.repositoryBuildStatus(for: .failed) == .failed)
+ #expect(RepositoryListViewModel.repositoryBuildStatus(for: .timeout) == .failed)
+ }
+
@MainActor
private func makeRepository(
id: Int,