summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj8
-rw-r--r--Hutch/App/AppState.swift46
-rw-r--r--Hutch/App/RootView.swift110
-rw-r--r--Hutch/Extensions/SRHTShareUI.swift1
-rw-r--r--Hutch/Extensions/SRHTWebURL.swift8
-rw-r--r--Hutch/Models/Inbox.swift2
-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.swift109
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift184
-rw-r--r--Hutch/Views/Inbox/InboxView.swift15
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift10
-rw-r--r--Hutch/Views/Inbox/ThreadDetailView.swift60
-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/Settings/SettingsView.swift26
-rw-r--r--HutchTests/ProjectTests.swift72
28 files changed, 2751 insertions, 66 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 7492fa6..d5a86fd 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -9,11 +9,16 @@ final class AppState {
enum Tab: Hashable {
case home
- case inbox
case repositories
- case builds
case tickets
- case settings
+ case builds
+ case more
+ }
+
+ enum TabNavigationTarget: Hashable {
+ case repository(RepositorySummary)
+ case tracker(TrackerSummary)
+ case mailingList(InboxMailingListReference)
}
enum AuthPhase {
@@ -48,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
@@ -126,9 +132,9 @@ final class AppState {
// 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
@@ -147,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 = """
@@ -221,6 +256,7 @@ final class AppState {
client.responseCache.clear()
currentUser = nil
pendingDeepLink = nil
+ pendingTabNavigation = nil
selectedTab = .home
}
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 042494f..0885835 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -5,7 +5,7 @@ import SwiftUI
struct RootView: View {
@Environment(AppState.self) private var appState
@State private var homePath = NavigationPath()
- @State private var inboxPath = NavigationPath()
+ @State private var morePath = NavigationPath()
@State private var repoPath = NavigationPath()
@State private var buildsPath = NavigationPath()
@State private var ticketsPath = NavigationPath()
@@ -34,6 +34,9 @@ struct RootView: View {
.onChange(of: appState.authPhase) { _, newPhase in
handleAuthPhaseChange(newPhase)
}
+ .onChange(of: appState.pendingTabNavigation) { _, newValue in
+ consumePendingTabNavigationIfPossible(newValue)
+ }
}
// MARK: - Tab View
@@ -50,14 +53,6 @@ struct RootView: View {
Label("Home", systemImage: "house")
}
- NavigationStack(path: $inboxPath) {
- InboxView()
- }
- .tag(AppState.Tab.inbox)
- .tabItem {
- Label("Inbox", systemImage: "tray")
- }
-
NavigationStack(path: $repoPath) {
RepositoryListView()
}
@@ -78,12 +73,6 @@ struct RootView: View {
Label("Tickets", systemImage: "ticket")
}
- SettingsView()
- .tag(AppState.Tab.settings)
- .tabItem {
- Label("Settings", systemImage: "gear")
- }
-
NavigationStack(path: $buildsPath) {
BuildListView()
// Int destination used by deep links (hutch://builds/<id>).
@@ -96,6 +85,14 @@ struct RootView: View {
.tabItem {
Label("Builds", systemImage: "hammer")
}
+
+ NavigationStack(path: $morePath) {
+ MoreNavigationRoot()
+ }
+ .tag(AppState.Tab.more)
+ .tabItem {
+ Label("More", systemImage: "ellipsis.circle")
+ }
}
.overlay {
if isResolvingDeepLink {
@@ -118,7 +115,7 @@ struct RootView: View {
break
case .unauthenticated:
homePath = NavigationPath()
- inboxPath = NavigationPath()
+ morePath = NavigationPath()
repoPath = NavigationPath()
buildsPath = NavigationPath()
ticketsPath = NavigationPath()
@@ -135,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 }
@@ -157,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 {
@@ -198,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
index 54e2685..c89cb12 100644
--- a/Hutch/Models/Inbox.swift
+++ b/Hutch/Models/Inbox.swift
@@ -150,7 +150,7 @@ struct MailComposeDraft: Sendable {
extension MailComposeDraft: Identifiable {}
-struct InboxMailingListReference: Decodable, Sendable, Hashable {
+struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable {
let id: Int
let rid: String
let name: String
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
index 1e98039..55ec41e 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -4,6 +4,7 @@ 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 {
@@ -14,6 +15,15 @@ struct HomeView: View {
}
}
.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)
@@ -26,16 +36,17 @@ struct HomeView: View {
@ViewBuilder
private func content(_ viewModel: HomeViewModel) -> some View {
List {
+ projectsSection(viewModel)
assignedTicketsSection(viewModel)
recentBuildsSection(viewModel)
}
.listStyle(.insetGrouped)
.overlay {
- if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
- viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
+ if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
+ viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
SRHTLoadingStateView(message: "Loading Home…")
- } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
- viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
+ } 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",
@@ -50,6 +61,25 @@ struct HomeView: View {
}
@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 {
@@ -123,6 +153,77 @@ struct HomeView: View {
}
+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
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index ce27bb0..31dec1b 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -33,6 +33,36 @@ 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
@@ -106,9 +136,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
@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
@@ -118,7 +151,10 @@ final class HomeViewModel {
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 {
@@ -177,12 +213,45 @@ final class HomeViewModel {
}
"""
+ 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
@@ -190,8 +259,19 @@ final class HomeViewModel {
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
@@ -222,6 +302,16 @@ final class HomeViewModel {
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> {
@@ -237,6 +327,100 @@ final class HomeViewModel {
}
}
+ 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()
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
index 602f8d9..304e62d 100644
--- a/Hutch/Views/Inbox/InboxView.swift
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -51,9 +51,9 @@ struct InboxView: View {
)
} else if viewModel.threads.isEmpty, viewModel.error == nil {
ContentUnavailableView(
- "No Threads",
+ "Inbox Zero",
systemImage: "tray",
- description: Text("Patch threads will appear here.")
+ description: Text("Unread threads will appear here.")
)
}
}
@@ -75,19 +75,16 @@ struct InboxView: View {
private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
- viewModel.toggleThreadReadState(thread)
+ viewModel.markThreadRead(thread)
}
} label: {
- Label(
- thread.isUnread ? "Mark as Read" : "Mark as Unread",
- systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
- )
+ Label("Mark as Read", systemImage: "envelope.open")
}
- .tint(thread.isUnread ? .blue : .gray)
+ .tint(.blue)
}
}
-private struct InboxThreadRow: View {
+struct InboxThreadRow: View {
let thread: InboxThreadSummary
var body: some View {
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index 9211b40..9c1ef45 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -127,7 +127,9 @@ final class InboxViewModel {
let subscriptions = try await fetchSubscriptions()
let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
let fetchedThreads = try await fetchThreads(for: mailingLists)
- threads = fetchedThreads.sorted { lhs, rhs in
+ threads = fetchedThreads
+ .filter(\.isUnread)
+ .sorted { lhs, rhs in
if lhs.lastActivityAt == rhs.lastActivityAt {
return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
}
@@ -145,7 +147,7 @@ final class InboxViewModel {
inboxListLogger.debug(
"Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
)
- updateThread(thread, isUnread: false)
+ threads.removeAll { $0.id == thread.id }
}
func markThreadUnread(_ thread: InboxThreadSummary) {
@@ -329,6 +331,10 @@ final class InboxViewModel {
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,
diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift
index 7677698..6fe835c 100644
--- a/Hutch/Views/Inbox/ThreadDetailView.swift
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -8,10 +8,29 @@ private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category:
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 {
@@ -23,13 +42,21 @@ struct ThreadDetailView: View {
}
.navigationTitle("Thread")
.navigationBarTitleDisplayMode(.inline)
- .task {
- if viewModel == nil {
- onViewed()
- let vm = ThreadViewModel(summary: thread, client: appState.client)
- viewModel = vm
- await vm.loadThread()
- }
+ .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 },
@@ -109,8 +136,23 @@ struct ThreadDetailView: View {
.listStyle(.plain)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
- Button("Reply") {
- viewModel.prepareReply()
+ 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()
+ }
}
}
}
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/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/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")
+ }
+}