Commit a9d2e38653

a9d2e38653e94f0f456b5db423390aac449554e1

parent: 4c312827dd

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 20:17 UTC

feat: implement support for projects, lists, and pastes

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +4 −4
@@ -363,7 +363,7 @@
363363 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
364364 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
365365 CODE_SIGN_STYLE = Automatic;
366 CURRENT_PROJECT_VERSION = 2;
366 CURRENT_PROJECT_VERSION = 5;
367367 DEVELOPMENT_TEAM = ZCNAX3VL9D;
368368 ENABLE_PREVIEWS = YES;
369369 GENERATE_INFOPLIST_FILE = YES;
@@ -380,7 +380,7 @@
380380 "$(inherited)",
381381 "@executable_path/Frameworks",
382382 );
383 MARKETING_VERSION = 1.2;
383 MARKETING_VERSION = 2.0;
384384 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
385385 PRODUCT_NAME = "$(TARGET_NAME)";
386386 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -399,7 +399,7 @@
399399 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
400400 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
401401 CODE_SIGN_STYLE = Automatic;
402 CURRENT_PROJECT_VERSION = 2;
402 CURRENT_PROJECT_VERSION = 5;
403403 DEVELOPMENT_TEAM = ZCNAX3VL9D;
404404 ENABLE_PREVIEWS = YES;
405405 GENERATE_INFOPLIST_FILE = YES;
@@ -416,7 +416,7 @@
416416 "$(inherited)",
417417 "@executable_path/Frameworks",
418418 );
419 MARKETING_VERSION = 1.2;
419 MARKETING_VERSION = 2.0;
420420 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
421421 PRODUCT_NAME = "$(TARGET_NAME)";
422422 STRING_CATALOG_GENERATE_SYMBOLS = YES;
Hutch/App/AppState.swift +41 −5
@@ -9,11 +9,16 @@ final class AppState {
99
1010 enum Tab: Hashable {
1111 case home
12 case inbox
1312 case repositories
14 case builds
1513 case tickets
16 case settings
14 case builds
15 case more
16 }
17
18 enum TabNavigationTarget: Hashable {
19 case repository(RepositorySummary)
20 case tracker(TrackerSummary)
21 case mailingList(InboxMailingListReference)
1722 }
1823
1924 enum AuthPhase {
@@ -48,6 +53,7 @@ final class AppState {
4853
4954 /// Set by the deep link handler; consumed by RootView to drive navigation.
5055 var pendingDeepLink: DeepLink?
56 var pendingTabNavigation: TabNavigationTarget?
5157
5258 // MARK: - Init
5359
@@ -126,9 +132,9 @@ final class AppState {
126132 // MARK: - Deep link resolution
127133
128134 /// Resolve a repository by owner and name for deep linking.
129 func resolveRepository(owner: String, name: String) async throws -> RepositorySummary {
135 func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary {
130136 let result = try await client.execute(
131 service: .git,
137 service: service,
132138 query: Self.repoLookupQuery,
133139 variables: ["owner": owner, "name": name],
134140 responseType: RepoLookupResponse.self
@@ -147,6 +153,35 @@ final class AppState {
147153 return result.user.tracker
148154 }
149155
156 func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary {
157 try await resolveRepository(
158 owner: source.ownerUsername,
159 name: source.name,
160 service: source.repoType.service
161 )
162 }
163
164 func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary {
165 try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name)
166 }
167
168 func openProjectSource(_ source: Project.SourceRepo) async throws {
169 let repository = try await resolveProjectSource(source)
170 pendingTabNavigation = .repository(repository)
171 selectedTab = .repositories
172 }
173
174 func openProjectTracker(_ tracker: Project.Tracker) async throws {
175 let resolvedTracker = try await resolveProjectTracker(tracker)
176 pendingTabNavigation = .tracker(resolvedTracker)
177 selectedTab = .tickets
178 }
179
180 func openMailingList(_ mailingList: InboxMailingListReference) {
181 pendingTabNavigation = .mailingList(mailingList)
182 selectedTab = .more
183 }
184
150185 // MARK: - Private
151186
152187 private static let meQuery = """
@@ -221,6 +256,7 @@ final class AppState {
221256 client.responseCache.clear()
222257 currentUser = nil
223258 pendingDeepLink = nil
259 pendingTabNavigation = nil
224260 selectedTab = .home
225261 }
226262
Hutch/App/RootView.swift +94 −16
@@ -5,7 +5,7 @@ import SwiftUI
55struct RootView: View {
66 @Environment(AppState.self) private var appState
77 @State private var homePath = NavigationPath()
8 @State private var inboxPath = NavigationPath()
8 @State private var morePath = NavigationPath()
99 @State private var repoPath = NavigationPath()
1010 @State private var buildsPath = NavigationPath()
1111 @State private var ticketsPath = NavigationPath()
@@ -34,6 +34,9 @@ struct RootView: View {
3434 .onChange(of: appState.authPhase) { _, newPhase in
3535 handleAuthPhaseChange(newPhase)
3636 }
37 .onChange(of: appState.pendingTabNavigation) { _, newValue in
38 consumePendingTabNavigationIfPossible(newValue)
39 }
3740 }
3841
3942 // MARK: - Tab View
@@ -50,14 +53,6 @@ struct RootView: View {
5053 Label("Home", systemImage: "house")
5154 }
5255
53 NavigationStack(path: $inboxPath) {
54 InboxView()
55 }
56 .tag(AppState.Tab.inbox)
57 .tabItem {
58 Label("Inbox", systemImage: "tray")
59 }
60
6156 NavigationStack(path: $repoPath) {
6257 RepositoryListView()
6358 }
@@ -78,12 +73,6 @@ struct RootView: View {
7873 Label("Tickets", systemImage: "ticket")
7974 }
8075
81 SettingsView()
82 .tag(AppState.Tab.settings)
83 .tabItem {
84 Label("Settings", systemImage: "gear")
85 }
86
8776 NavigationStack(path: $buildsPath) {
8877 BuildListView()
8978 // Int destination used by deep links (hutch://builds/<id>).
@@ -96,6 +85,14 @@ struct RootView: View {
9685 .tabItem {
9786 Label("Builds", systemImage: "hammer")
9887 }
88
89 NavigationStack(path: $morePath) {
90 MoreNavigationRoot()
91 }
92 .tag(AppState.Tab.more)
93 .tabItem {
94 Label("More", systemImage: "ellipsis.circle")
95 }
9996 }
10097 .overlay {
10198 if isResolvingDeepLink {
@@ -118,7 +115,7 @@ struct RootView: View {
118115 break
119116 case .unauthenticated:
120117 homePath = NavigationPath()
121 inboxPath = NavigationPath()
118 morePath = NavigationPath()
122119 repoPath = NavigationPath()
123120 buildsPath = NavigationPath()
124121 ticketsPath = NavigationPath()
@@ -135,6 +132,12 @@ struct RootView: View {
135132 appState.pendingDeepLink = nil
136133 }
137134
135 private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
136 guard appState.isAuthenticated, let target else { return }
137 handleTabNavigation(target)
138 appState.pendingTabNavigation = nil
139 }
140
138141 private func handleDeepLink(_ link: DeepLink) {
139142 guard appState.isAuthenticated else { return }
140143
@@ -157,6 +160,36 @@ struct RootView: View {
157160 }
158161 }
159162
163 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
164 switch target {
165 case .repository(let repository):
166 repoPath = NavigationPath()
167 appState.selectedTab = .repositories
168 Task { @MainActor in
169 try? await Task.sleep(for: .milliseconds(100))
170 repoPath.append(repository)
171 }
172
173 case .tracker(let tracker):
174 ticketsPath = NavigationPath()
175 appState.selectedTab = .tickets
176 Task { @MainActor in
177 try? await Task.sleep(for: .milliseconds(100))
178 ticketsPath.append(tracker)
179 }
180
181 case .mailingList(let mailingList):
182 morePath = NavigationPath()
183 appState.selectedTab = .more
184 Task { @MainActor in
185 try? await Task.sleep(for: .milliseconds(100))
186 morePath.append(MoreRoute.lists)
187 try? await Task.sleep(for: .milliseconds(100))
188 morePath.append(MoreRoute.mailingList(mailingList))
189 }
190 }
191 }
192
160193 private func resolveRepositoryLink(owner: String, repo: String) {
161194 isResolvingDeepLink = true
162195 Task {
@@ -198,6 +231,51 @@ struct RootView: View {
198231 }
199232}
200233
234enum MoreDestination: Hashable {
235 case lists
236 case pastes
237 case settings
238}
239
240enum MoreRoute: Hashable {
241 case lists
242 case pastes
243 case settings
244 case mailingList(InboxMailingListReference)
245 case thread(InboxThreadSummary)
246}
247
248private struct MoreNavigationRoot: View {
249 var body: some View {
250 MoreView()
251 .navigationDestination(for: MoreRoute.self) { route in
252 switch route {
253 case .lists:
254 MailingListListView()
255 case .pastes:
256 PasteListView()
257 case .settings:
258 SettingsView()
259 case .mailingList(let mailingList):
260 MailingListDetailView(mailingList: mailingList)
261 case .thread(let thread):
262 ThreadDetailView(
263 thread: thread,
264 onViewed: {
265 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
266 },
267 onMarkRead: {
268 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
269 },
270 onMarkUnread: {
271 InboxReadStateStore.markUnread(for: thread.id)
272 }
273 )
274 }
275 }
276 }
277}
278
201279// MARK: - Ticket Deep Link Navigation Target
202280
203281/// Hashable wrapper to push a ticket detail view from a deep link.
Hutch/Extensions/SRHTShareUI.swift +1
@@ -9,6 +9,7 @@ enum SRHTShareTarget: String {
99 case tracker = "tracker"
1010 case ticket = "ticket"
1111 case profile = "profile"
12 case paste = "paste"
1213
1314 var fallbackMessage: String {
1415 "This \(rawValue) does not have a valid web URL to share."
Hutch/Extensions/SRHTWebURL.swift +8
@@ -76,6 +76,14 @@ enum SRHTWebURL {
7676 )
7777 }
7878
79 static func paste(ownerCanonicalName: String, pasteId: String) -> URL? {
80 userScopedURL(
81 host: "paste.sr.ht",
82 ownerCanonicalName: ownerCanonicalName,
83 pathComponents: [pasteId]
84 )
85 }
86
7987 private static func userScopedURL(
8088 host: String,
8189 ownerCanonicalName: String,
Hutch/Models/Inbox.swift +1 −1
@@ -150,7 +150,7 @@ struct MailComposeDraft: Sendable {
150150
151151extension MailComposeDraft: Identifiable {}
152152
153struct InboxMailingListReference: Decodable, Sendable, Hashable {
153struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable {
154154 let id: Int
155155 let rid: String
156156 let name: String
Hutch/Models/Paste.swift added +29
@@ -0,0 +1,29 @@
1import Foundation
2
3struct PasteFile: Codable, Sendable, Hashable, Identifiable {
4 let filename: String?
5 let hash: String
6 let contents: URL?
7
8 var id: String { hash }
9}
10
11struct Paste: Codable, Sendable, Identifiable, Hashable {
12 let id: String
13 let created: Date
14 let visibility: Visibility
15 let files: [PasteFile]
16 let user: Entity
17}
18
19struct PasteUploadDraft: Identifiable, Equatable, Sendable {
20 let id: UUID
21 var filename: String
22 var contents: String
23
24 init(id: UUID = UUID(), filename: String = "", contents: String = "") {
25 self.id = id
26 self.filename = filename
27 self.contents = contents
28 }
29}
Hutch/Models/Project.swift added +101
@@ -0,0 +1,101 @@
1import Foundation
2
3struct Project: Identifiable, Hashable, Sendable {
4 struct MailingList: Identifiable, Hashable, Sendable {
5 let id: String
6 let name: String
7 let description: String?
8 let visibility: Visibility
9 let owner: Entity
10
11 var ownerUsername: String {
12 owner.canonicalName.srhtUsername
13 }
14
15 var inboxReference: InboxMailingListReference {
16 InboxMailingListReference(
17 id: 0,
18 rid: id,
19 name: name,
20 owner: owner
21 )
22 }
23 }
24
25 struct SourceRepo: Identifiable, Hashable, Sendable {
26 enum RepoType: String, Decodable, Sendable {
27 case git = "GIT"
28 case hg = "HG"
29
30 var service: SRHTService {
31 switch self {
32 case .git: .git
33 case .hg: .hg
34 }
35 }
36 }
37
38 let id: String
39 let name: String
40 let description: String?
41 let visibility: Visibility
42 let owner: Entity
43 let repoType: RepoType
44
45 var ownerUsername: String {
46 owner.canonicalName.srhtUsername
47 }
48 }
49
50 struct Tracker: Identifiable, Hashable, Sendable {
51 let id: String
52 let name: String
53 let description: String?
54 let visibility: Visibility
55 let owner: Entity
56
57 var ownerUsername: String {
58 owner.canonicalName.srhtUsername
59 }
60 }
61
62 let id: String
63 let name: String
64 let description: String?
65 let website: String?
66 let visibility: Visibility
67 let tags: [String]
68 let mailingLists: [MailingList]
69 let sources: [SourceRepo]
70 let trackers: [Tracker]
71
72 var resourceSummary: String? {
73 let parts = [
74 Self.resourceCountText(count: sources.count, singular: "repo"),
75 Self.resourceCountText(count: trackers.count, singular: "tracker"),
76 Self.resourceCountText(count: mailingLists.count, singular: "list")
77 ].compactMap { $0 }
78
79 if !parts.isEmpty {
80 return parts.joined(separator: " • ")
81 }
82
83 if let website, !website.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
84 return "Website linked"
85 }
86
87 return nil
88 }
89
90 private static func resourceCountText(count: Int, singular: String) -> String? {
91 guard count > 0 else { return nil }
92 let label = count == 1 ? singular : "\(singular)s"
93 return "\(count) \(label)"
94 }
95}
96
97extension String {
98 var srhtUsername: String {
99 hasPrefix("~") ? String(dropFirst()) : self
100 }
101}
Hutch/Networking/GraphQLRequest.swift +2
@@ -34,6 +34,8 @@ struct AnyCodable: Sendable, Encodable {
3434 try container.encode(v)
3535 case let v as Bool:
3636 try container.encode(v)
37 case let v as [String?]:
38 try container.encode(v)
3739 case let v as [any Sendable]:
3840 try container.encode(v.map { AnyCodable($0) })
3941 case let v as [String: any Sendable]:
Hutch/Networking/PasteService.swift added +231
@@ -0,0 +1,231 @@
1import Foundation
2
3struct PasteListPage: Decodable, Sendable {
4 let results: [Paste]
5 let cursor: String?
6}
7
8final class PasteService: Sendable {
9 private let client: SRHTClient
10
11 init(client: SRHTClient) {
12 self.client = client
13 }
14
15 private static let listQuery = """
16 query pastes($cursor: Cursor) {
17 pastes(cursor: $cursor) {
18 results {
19 id
20 created
21 visibility
22 files {
23 filename
24 hash
25 }
26 user {
27 canonicalName
28 }
29 }
30 cursor
31 }
32 }
33 """
34
35 private static let detailQuery = """
36 query paste($id: String!) {
37 paste(id: $id) {
38 id
39 created
40 visibility
41 files {
42 filename
43 hash
44 contents
45 }
46 user {
47 canonicalName
48 }
49 }
50 }
51 """
52
53 private static let createMutation = """
54 mutation createPaste($files: [Upload!]!, $visibility: Visibility!) {
55 create(files: $files, visibility: $visibility) {
56 id
57 created
58 visibility
59 files {
60 filename
61 hash
62 contents
63 }
64 user {
65 canonicalName
66 }
67 }
68 }
69 """
70
71 private static let updateMutation = """
72 mutation updatePaste($id: String!, $visibility: Visibility!) {
73 update(id: $id, visibility: $visibility) {
74 id
75 created
76 visibility
77 files {
78 filename
79 hash
80 contents
81 }
82 user {
83 canonicalName
84 }
85 }
86 }
87 """
88
89 private static let deleteMutation = """
90 mutation deletePaste($id: String!) {
91 delete(id: $id) {
92 id
93 created
94 visibility
95 files {
96 filename
97 hash
98 }
99 user {
100 canonicalName
101 }
102 }
103 }
104 """
105
106 private static let cacheKey = "paste.pastes"
107
108 func listPastes(cursor: String?, useCache: Bool) async throws -> PasteListPage {
109 let variables = cursor.map { ["cursor": $0 as any Sendable] }
110 let result: PasteListResponse
111 if useCache, cursor == nil {
112 result = try await client.executeAndCache(
113 service: .paste,
114 query: Self.listQuery,
115 variables: variables,
116 responseType: PasteListResponse.self,
117 cacheKey: Self.cacheKey
118 )
119 } else {
120 result = try await client.execute(
121 service: .paste,
122 query: Self.listQuery,
123 variables: variables,
124 responseType: PasteListResponse.self
125 )
126 }
127 return result.pastes ?? PasteListPage(results: [], cursor: nil)
128 }
129
130 func loadCachedPastes() -> PasteListPage? {
131 guard let data = client.responseCache.get(forKey: Self.cacheKey) else {
132 return nil
133 }
134
135 let decoder = JSONDecoder()
136 decoder.dateDecodingStrategy = .srhtFlexible
137 guard let response = try? decoder.decode(GraphQLResponse<PasteListResponse>.self, from: data) else {
138 return nil
139 }
140 return response.data?.pastes
141 }
142
143 func loadPaste(id: String) async throws -> Paste? {
144 let result = try await client.execute(
145 service: .paste,
146 query: Self.detailQuery,
147 variables: ["id": id],
148 responseType: PasteDetailResponse.self
149 )
150 return result.paste
151 }
152
153 func createPaste(files: [PasteUploadDraft], visibility: Visibility) async throws -> Paste {
154 let uploadFiles = normalizedUploadFiles(from: files)
155 let variables: [String: any Sendable] = [
156 "files": [String?](repeating: nil, count: uploadFiles.count),
157 "visibility": visibility.rawValue
158 ]
159
160 let result = try await client.executeMultipartFiles(
161 service: .paste,
162 query: Self.createMutation,
163 variables: variables,
164 files: uploadFiles.enumerated().map { index, file in
165 MultipartUploadFile(
166 variablePath: "files.\(index)",
167 fileData: file.data,
168 fileName: file.fileName,
169 mimeType: "text/plain"
170 )
171 },
172 responseType: CreatePasteResponse.self
173 )
174 return result.create
175 }
176
177 func updateVisibility(id: String, visibility: Visibility) async throws -> Paste? {
178 let result = try await client.execute(
179 service: .paste,
180 query: Self.updateMutation,
181 variables: ["id": id, "visibility": visibility.rawValue],
182 responseType: UpdatePasteResponse.self
183 )
184 return result.update
185 }
186
187 func deletePaste(id: String) async throws -> Paste? {
188 let result = try await client.execute(
189 service: .paste,
190 query: Self.deleteMutation,
191 variables: ["id": id],
192 responseType: DeletePasteResponse.self
193 )
194 return result.delete
195 }
196
197 func loadContents(from url: URL) async throws -> String {
198 try await client.fetchText(url: url)
199 }
200
201 private func normalizedUploadFiles(from files: [PasteUploadDraft]) -> [(fileName: String, data: Data)] {
202 files.compactMap { draft in
203 let text = draft.contents
204 guard let data = text.data(using: .utf8) else {
205 return nil
206 }
207
208 return (draft.filename, data)
209 }
210 }
211}
212
213private struct PasteListResponse: Decodable, Sendable {
214 let pastes: PasteListPage?
215}
216
217private struct PasteDetailResponse: Decodable, Sendable {
218 let paste: Paste?
219}
220
221private struct CreatePasteResponse: Decodable, Sendable {
222 let create: Paste
223}
224
225private struct UpdatePasteResponse: Decodable, Sendable {
226 let update: Paste?
227}
228
229private struct DeletePasteResponse: Decodable, Sendable {
230 let delete: Paste?
231}
Hutch/Networking/ProjectService.swift added +284
@@ -0,0 +1,284 @@
1import Foundation
2
3private struct ProjectPageResponse: Decodable, Sendable {
4 let me: ProjectPageUser
5}
6
7private struct ProjectPageUser: Decodable, Sendable {
8 let projects: ProjectPage
9}
10
11private struct ProjectPage: Decodable, Sendable {
12 let results: [ProjectSummaryPayload]
13 let cursor: String?
14}
15
16private struct ProjectSummaryPayload: Decodable, Sendable {
17 let rid: String
18 let name: String
19 let description: String?
20 let website: String?
21 let visibility: Visibility
22 let tags: [String]
23}
24
25private struct ProjectDetailResponse: Decodable, Sendable {
26 let project: ProjectDetailPayload?
27}
28
29private struct ProjectDetailPayload: Decodable, Sendable {
30 let rid: String
31 let name: String
32 let description: String?
33 let website: String?
34 let visibility: Visibility
35 let tags: [String]
36 let mailingLists: ProjectMailingListPage
37 let sources: ProjectSourcePage
38 let trackers: ProjectTrackerPage
39}
40
41private struct ProjectMailingListPage: Decodable, Sendable {
42 let results: [ProjectMailingListPayload]
43 let cursor: String?
44}
45
46private struct ProjectMailingListPayload: Decodable, Sendable {
47 let rid: String
48 let name: String
49 let description: String?
50 let visibility: Visibility
51 let owner: Entity
52}
53
54private struct ProjectSourcePage: Decodable, Sendable {
55 let results: [ProjectSourcePayload]
56 let cursor: String?
57}
58
59private struct ProjectSourcePayload: Decodable, Sendable {
60 let rid: String
61 let name: String
62 let description: String?
63 let visibility: Visibility
64 let owner: Entity
65 let repoType: Project.SourceRepo.RepoType
66}
67
68private struct ProjectTrackerPage: Decodable, Sendable {
69 let results: [ProjectTrackerPayload]
70 let cursor: String?
71}
72
73private struct ProjectTrackerPayload: Decodable, Sendable {
74 let rid: String
75 let name: String
76 let description: String?
77 let visibility: Visibility
78 let owner: Entity
79}
80
81struct ProjectService: Sendable {
82 private let client: SRHTClient
83
84 private static let projectsQuery = """
85 query meProjects($cursor: Cursor) {
86 me {
87 projects(cursor: $cursor) {
88 results {
89 rid
90 name
91 description
92 website
93 visibility
94 tags
95 }
96 cursor
97 }
98 }
99 }
100 """
101
102 private static let projectDetailQuery = """
103 query projectDetail($rid: ID!, $mailingListsCursor: Cursor, $sourcesCursor: Cursor, $trackersCursor: Cursor) {
104 project(rid: $rid) {
105 rid
106 name
107 description
108 website
109 visibility
110 tags
111 mailingLists(cursor: $mailingListsCursor) {
112 results {
113 rid
114 name
115 description
116 visibility
117 owner { canonicalName }
118 }
119 cursor
120 }
121 sources(cursor: $sourcesCursor) {
122 results {
123 rid
124 name
125 description
126 visibility
127 owner { canonicalName }
128 repoType
129 }
130 cursor
131 }
132 trackers(cursor: $trackersCursor) {
133 results {
134 rid
135 name
136 description
137 visibility
138 owner { canonicalName }
139 }
140 cursor
141 }
142 }
143 }
144 """
145
146 init(client: SRHTClient) {
147 self.client = client
148 }
149
150 func fetchProjects() async throws -> [Project] {
151 let summaries = try await fetchProjectSummaries()
152 guard !summaries.isEmpty else { return [] }
153
154 return try await withThrowingTaskGroup(of: Project.self) { group in
155 for summary in summaries {
156 group.addTask {
157 try await self.fetchProjectDetail(summary: summary)
158 }
159 }
160
161 var projects: [Project] = []
162 for try await project in group {
163 projects.append(project)
164 }
165 return projects.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
166 }
167 }
168
169 private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] {
170 var results: [ProjectSummaryPayload] = []
171 var cursor: String?
172
173 while true {
174 var variables: [String: any Sendable] = [:]
175 if let cursor {
176 variables["cursor"] = cursor
177 }
178
179 let response = try await client.execute(
180 service: .hub,
181 query: Self.projectsQuery,
182 variables: variables.isEmpty ? nil : variables,
183 responseType: ProjectPageResponse.self
184 )
185
186 results.append(contentsOf: response.me.projects.results)
187 guard let nextCursor = response.me.projects.cursor else {
188 break
189 }
190 cursor = nextCursor
191 }
192
193 return results
194 }
195
196 private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project {
197 var mailingLists: [Project.MailingList] = []
198 var sources: [Project.SourceRepo] = []
199 var trackers: [Project.Tracker] = []
200 var mailingListsCursor: String?
201 var sourcesCursor: String?
202 var trackersCursor: String?
203
204 while true {
205 var variables: [String: any Sendable] = ["rid": summary.rid]
206 if let mailingListsCursor {
207 variables["mailingListsCursor"] = mailingListsCursor
208 }
209 if let sourcesCursor {
210 variables["sourcesCursor"] = sourcesCursor
211 }
212 if let trackersCursor {
213 variables["trackersCursor"] = trackersCursor
214 }
215
216 let response = try await client.execute(
217 service: .hub,
218 query: Self.projectDetailQuery,
219 variables: variables,
220 responseType: ProjectDetailResponse.self
221 )
222
223 guard let project = response.project else {
224 throw SRHTError.decodingError(
225 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing project payload"))
226 )
227 }
228
229 mailingLists.append(contentsOf: project.mailingLists.results.map {
230 Project.MailingList(
231 id: $0.rid,
232 name: $0.name,
233 description: $0.description,
234 visibility: $0.visibility,
235 owner: $0.owner
236 )
237 })
238 sources.append(contentsOf: project.sources.results.map {
239 Project.SourceRepo(
240 id: $0.rid,
241 name: $0.name,
242 description: $0.description,
243 visibility: $0.visibility,
244 owner: $0.owner,
245 repoType: $0.repoType
246 )
247 })
248 trackers.append(contentsOf: project.trackers.results.map {
249 Project.Tracker(
250 id: $0.rid,
251 name: $0.name,
252 description: $0.description,
253 visibility: $0.visibility,
254 owner: $0.owner
255 )
256 })
257
258 mailingListsCursor = project.mailingLists.cursor
259 sourcesCursor = project.sources.cursor
260 trackersCursor = project.trackers.cursor
261
262 if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
263 return Project(
264 id: project.rid,
265 name: project.name,
266 description: project.description,
267 website: project.website,
268 visibility: project.visibility,
269 tags: project.tags,
270 mailingLists: deduplicate(mailingLists),
271 sources: deduplicate(sources),
272 trackers: deduplicate(trackers)
273 )
274 }
275 }
276 }
277
278 private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
279 var seen = Set<T.ID>()
280 return items.filter { item in
281 seen.insert(item.id).inserted
282 }
283 }
284}
Hutch/Networking/SRHTClient.swift +101
@@ -3,6 +3,13 @@ import os
33
44private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient")
55
6struct MultipartUploadFile: Sendable {
7 let variablePath: String
8 let fileData: Data
9 let fileName: String
10 let mimeType: String
11}
12
613/// Placeholder type for decoding GraphQL error responses when the data shape is unknown.
714private struct EmptyData: Decodable {}
815
@@ -298,6 +305,100 @@ final class SRHTClient: Sendable {
298305 return result
299306 }
300307
308 func executeMultipartFiles<T: Decodable>(
309 service: SRHTService,
310 query: String,
311 variables: [String: any Sendable],
312 files: [MultipartUploadFile],
313 responseType: T.Type
314 ) async throws -> T {
315 guard let token = _token.withLock({ $0 }), !token.isEmpty else {
316 throw SRHTError.unauthorized
317 }
318
319 let boundary = "Boundary-\(UUID().uuidString)"
320
321 var request = URLRequest(url: service.url)
322 request.httpMethod = "POST"
323 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
324 request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
325
326 let operationsBody = GraphQLRequestBody(
327 query: query,
328 variables: variables.mapValues { AnyCodable($0) }
329 )
330 let operationsData = try encoder.encode(operationsBody)
331
332 let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in
333 (String(index), ["variables.\(file.variablePath)"])
334 })
335 let mapData = try encoder.encode(mapDict)
336
337 var body = Data()
338
339 body.append("--\(boundary)\r\n")
340 body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
341 body.append("Content-Type: application/json\r\n\r\n")
342 body.append(operationsData)
343 body.append("\r\n")
344
345 body.append("--\(boundary)\r\n")
346 body.append("Content-Disposition: form-data; name=\"map\"\r\n")
347 body.append("Content-Type: application/json\r\n\r\n")
348 body.append(mapData)
349 body.append("\r\n")
350
351 for (index, file) in files.enumerated() {
352 body.append("--\(boundary)\r\n")
353 body.append("Content-Disposition: form-data; name=\"\(index)\"; filename=\"\(file.fileName)\"\r\n")
354 body.append("Content-Type: \(file.mimeType)\r\n\r\n")
355 body.append(file.fileData)
356 body.append("\r\n")
357 }
358
359 body.append("--\(boundary)--\r\n")
360 request.httpBody = body
361
362 let (data, response): (Data, URLResponse)
363 do {
364 (data, response) = try await session.data(for: request)
365 } catch {
366 throw SRHTError.networkError(error)
367 }
368
369 if let http = response as? HTTPURLResponse {
370 if http.statusCode == 401 {
371 throw SRHTError.unauthorized
372 }
373 if !(200...299).contains(http.statusCode) {
374 if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
375 let errors = gqlResponse.errors, !errors.isEmpty {
376 throw SRHTError.graphQLErrors(errors)
377 }
378 throw SRHTError.httpError(http.statusCode)
379 }
380 }
381
382 let graphQLResponse: GraphQLResponse<T>
383 do {
384 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
385 } catch {
386 throw SRHTError.decodingError(error)
387 }
388
389 if let errors = graphQLResponse.errors, !errors.isEmpty {
390 throw SRHTError.graphQLErrors(errors)
391 }
392
393 guard let result = graphQLResponse.data else {
394 throw SRHTError.decodingError(
395 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
396 )
397 }
398
399 return result
400 }
401
301402 // MARK: - Cached Execute
302403
303404 /// Execute a query and cache the raw response data. Returns cached data
Hutch/Networking/SRHTService.swift +9 −2
@@ -3,6 +3,7 @@ import Foundation
33/// Each Sourcehut service exposes its own GraphQL endpoint.
44enum SRHTService: String, Codable, Sendable, CaseIterable {
55 case meta
6 case hub
67 case git
78 case hg
89 case builds
@@ -14,13 +15,19 @@ enum SRHTService: String, Codable, Sendable, CaseIterable {
1415
1516 /// The GraphQL endpoint URL for this service.
1617 var url: URL {
17 // Force-unwrap is safe here — these are compile-time constant strings.
18 URL(string: "https://\(rawValue).sr.ht/query")!
18 switch self {
19 case .hub:
20 URL(string: "https://sr.ht/query")!
21 default:
22 // Force-unwrap is safe here — these are compile-time constant strings.
23 URL(string: "https://\(rawValue).sr.ht/query")!
24 }
1925 }
2026
2127 var displayName: String {
2228 switch self {
2329 case .meta: "Meta"
30 case .hub: "Hub"
2431 case .git: "Git"
2532 case .hg: "Mercurial"
2633 case .builds: "Builds"
Hutch/Views/Home/HomeView.swift +105 −4
@@ -4,6 +4,7 @@ struct HomeView: View {
44 @Environment(AppState.self) private var appState
55 @State private var viewModel: HomeViewModel?
66 private let previewLimit = 4
7 private let projectPreviewLimit = 3
78
89 var body: some View {
910 Group {
@@ -14,6 +15,15 @@ struct HomeView: View {
1415 }
1516 }
1617 .navigationTitle("Home")
18 .toolbar {
19 ToolbarItem(placement: .topBarTrailing) {
20 NavigationLink {
21 InboxView()
22 } label: {
23 HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
24 }
25 }
26 }
1727 .task {
1828 if viewModel == nil, let currentUser = appState.currentUser {
1929 let vm = HomeViewModel(currentUser: currentUser, client: appState.client)
@@ -26,16 +36,17 @@ struct HomeView: View {
2636 @ViewBuilder
2737 private func content(_ viewModel: HomeViewModel) -> some View {
2838 List {
39 projectsSection(viewModel)
2940 assignedTicketsSection(viewModel)
3041 recentBuildsSection(viewModel)
3142 }
3243 .listStyle(.insetGrouped)
3344 .overlay {
34 if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
35 viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
45 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
46 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
3647 SRHTLoadingStateView(message: "Loading Home…")
37 } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
38 viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
48 } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
49 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
3950 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
4051 ContentUnavailableView(
4152 "All Clear",
@@ -49,6 +60,25 @@ struct HomeView: View {
4960 }
5061 }
5162
63 @ViewBuilder
64 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
65 if !viewModel.projects.isEmpty {
66 Section {
67 ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
68 NavigationLink {
69 ProjectDetailView(project: project)
70 } label: {
71 HomeProjectRow(project: project)
72 }
73 }
74 } header: {
75 HomeSectionHeader("Projects") {
76 HomeProjectsListView(viewModel: viewModel)
77 }
78 }
79 }
80 }
81
5282 @ViewBuilder
5383 private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
5484 Section {
@@ -123,6 +153,77 @@ struct HomeView: View {
123153
124154}
125155
156private struct HomeInboxToolbarIcon: View {
157 let hasUnreadThreads: Bool
158
159 var body: some View {
160 ZStack(alignment: .topTrailing) {
161 Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
162
163 if hasUnreadThreads {
164 Circle()
165 .fill(.blue)
166 .frame(width: 9, height: 9)
167 .offset(x: 4, y: -2)
168 }
169 }
170 .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
171 }
172}
173
174private struct HomeProjectRow: View {
175 let project: Project
176
177 var body: some View {
178 VStack(alignment: .leading, spacing: 4) {
179 Text(project.name)
180 .font(.subheadline.weight(.medium))
181 .lineLimit(1)
182
183 if let description = project.description, !description.isEmpty {
184 Text(description)
185 .font(.caption)
186 .foregroundStyle(.secondary)
187 .lineLimit(1)
188 }
189
190 if let summary = project.resourceSummary {
191 Text(summary)
192 .font(.caption)
193 .foregroundStyle(.tertiary)
194 .lineLimit(1)
195 }
196 }
197 .padding(.vertical, 2)
198 }
199}
200
201private struct HomeProjectsListView: View {
202 let viewModel: HomeViewModel
203
204 var body: some View {
205 List {
206 ForEach(viewModel.projects) { project in
207 NavigationLink {
208 ProjectDetailView(project: project)
209 } label: {
210 HomeProjectRow(project: project)
211 }
212 }
213 }
214 .navigationTitle("Projects")
215 .navigationBarTitleDisplayMode(.inline)
216 .refreshable {
217 await viewModel.loadDashboard()
218 }
219 .overlay {
220 if viewModel.isLoadingProjects && viewModel.projects.isEmpty {
221 SRHTLoadingStateView(message: "Loading projects…")
222 }
223 }
224 }
225}
226
126227private struct HomeBuildRow: View {
127228 let build: HomeBuildItem
128229
Hutch/Views/Home/HomeViewModel.swift +184
@@ -33,6 +33,36 @@ private struct HomeTrackerTicketsPage: Decodable, Sendable {
3333 let results: [HomeTicketPayload]
3434}
3535
36private struct HomeInboxSubscriptionsResponse: Decodable, Sendable {
37 let subscriptions: HomeInboxSubscriptionPage
38}
39
40private struct HomeInboxSubscriptionPage: Decodable, Sendable {
41 let results: [HomeInboxSubscription]
42 let cursor: String?
43}
44
45private struct HomeInboxSubscription: Decodable, Sendable {
46 let list: InboxMailingListReference?
47}
48
49private struct HomeInboxListThreadsResponse: Decodable, Sendable {
50 let list: HomeInboxMailingListThreads
51}
52
53private struct HomeInboxMailingListThreads: Decodable, Sendable {
54 let threads: HomeInboxThreadPage
55}
56
57private struct HomeInboxThreadPage: Decodable, Sendable {
58 let results: [HomeInboxThreadPayload]
59}
60
61private struct HomeInboxThreadPayload: Decodable, Sendable {
62 let updated: Date
63 let subject: String
64}
65
3666private struct HomeTicketPayload: Decodable, Sendable {
3767 let id: Int
3868 let title: String
@@ -106,9 +136,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
106136@Observable
107137@MainActor
108138final class HomeViewModel {
139 private(set) var projects: [Project] = []
109140 private(set) var failedBuilds: [HomeBuildItem] = []
110141 private(set) var assignedTickets: [HomeAssignedTicket] = []
111142 private(set) var recentBuilds: [HomeBuildItem] = []
143 private(set) var hasUnreadInboxThreads = false
144 private(set) var isLoadingProjects = false
112145 private(set) var isLoadingFailedBuilds = false
113146 private(set) var isLoadingAssignedTickets = false
114147 private(set) var isLoadingRecentBuilds = false
@@ -118,7 +151,10 @@ final class HomeViewModel {
118151
119152 private let currentUser: User
120153 private let client: SRHTClient
154 private let projectService: ProjectService
121155 private let ticketFetchConcurrencyLimit = 6
156 private let inboxUnreadConcurrencyLimit = 4
157 private let inboxUnreadThreadPreviewLimit = 10
122158
123159 private static let jobsQuery = """
124160 query jobs {
@@ -177,12 +213,45 @@ final class HomeViewModel {
177213 }
178214 """
179215
216 private static let inboxSubscriptionsQuery = """
217 query inboxSubscriptions($cursor: Cursor) {
218 subscriptions(cursor: $cursor) {
219 results {
220 ... on MailingListSubscription {
221 list {
222 id
223 rid
224 name
225 owner { canonicalName }
226 }
227 }
228 }
229 cursor
230 }
231 }
232 """
233
234 private static let inboxListThreadsQuery = """
235 query inboxListThreads($rid: ID!) {
236 list(rid: $rid) {
237 threads {
238 results {
239 updated
240 subject
241 }
242 }
243 }
244 }
245 """
246
180247 init(currentUser: User, client: SRHTClient) {
181248 self.currentUser = currentUser
182249 self.client = client
250 self.projectService = ProjectService(client: client)
183251 }
184252
185253 func loadDashboard() async {
254 isLoadingProjects = true
186255 isLoadingFailedBuilds = true
187256 isLoadingAssignedTickets = true
188257 isLoadingRecentBuilds = true
@@ -190,8 +259,19 @@ final class HomeViewModel {
190259 assignedTicketsError = nil
191260 recentBuildsError = nil
192261
262 async let projectsTask = loadProjects()
193263 async let jobsTask = loadRecentJobs()
194264 async let assignedTicketsTask = loadAssignedTickets()
265 async let inboxUnreadTask = loadInboxUnreadState()
266
267 let projectsResult = await projectsTask
268 switch projectsResult {
269 case .success(let projects):
270 self.projects = projects
271 case .failure:
272 self.projects = []
273 }
274 isLoadingProjects = false
195275
196276 let recentJobsResult = await jobsTask
197277
@@ -222,6 +302,16 @@ final class HomeViewModel {
222302 self.assignedTicketsError = error.localizedDescription
223303 }
224304 isLoadingAssignedTickets = false
305
306 hasUnreadInboxThreads = (await inboxUnreadTask) ?? false
307 }
308
309 private func loadProjects() async -> Result<[Project], Error> {
310 do {
311 return .success(try await projectService.fetchProjects())
312 } catch {
313 return .failure(error)
314 }
225315 }
226316
227317 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
@@ -237,6 +327,100 @@ final class HomeViewModel {
237327 }
238328 }
239329
330 private func loadInboxUnreadState() async -> Bool? {
331 do {
332 return try await fetchHasUnreadInboxThreads()
333 } catch {
334 return nil
335 }
336 }
337
338 private func fetchHasUnreadInboxThreads() async throws -> Bool {
339 let mailingLists = try await fetchInboxMailingLists()
340 guard !mailingLists.isEmpty else { return false }
341
342 var startIndex = mailingLists.startIndex
343 while startIndex < mailingLists.endIndex {
344 let endIndex = mailingLists.index(
345 startIndex,
346 offsetBy: inboxUnreadConcurrencyLimit,
347 limitedBy: mailingLists.endIndex
348 ) ?? mailingLists.endIndex
349 let batch = Array(mailingLists[startIndex..<endIndex])
350
351 let batchHasUnread = await withTaskGroup(of: Bool.self) { group in
352 for mailingList in batch {
353 group.addTask {
354 (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false
355 }
356 }
357
358 for await hasUnread in group {
359 if hasUnread {
360 group.cancelAll()
361 return true
362 }
363 }
364 return false
365 }
366
367 if batchHasUnread {
368 return true
369 }
370
371 startIndex = endIndex
372 }
373
374 return false
375 }
376
377 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
378 var subscriptions: [HomeInboxSubscription] = []
379 var cursor: String?
380
381 while true {
382 var variables: [String: any Sendable] = [:]
383 if let cursor {
384 variables["cursor"] = cursor
385 }
386
387 let response = try await client.execute(
388 service: .lists,
389 query: Self.inboxSubscriptionsQuery,
390 variables: variables.isEmpty ? nil : variables,
391 responseType: HomeInboxSubscriptionsResponse.self
392 )
393
394 subscriptions.append(contentsOf: response.subscriptions.results)
395 guard let nextCursor = response.subscriptions.cursor else {
396 break
397 }
398 cursor = nextCursor
399 }
400
401 var seen = Set<String>()
402 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
403 }
404
405 private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool {
406 let response = try await client.execute(
407 service: .lists,
408 query: Self.inboxListThreadsQuery,
409 variables: ["rid": mailingList.rid],
410 responseType: HomeInboxListThreadsResponse.self
411 )
412
413 return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in
414 let normalizedSubject = thread.subject
415 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
416 .trimmingCharacters(in: .whitespacesAndNewlines)
417 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
418 .lowercased()
419 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
420 return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
421 }
422 }
423
240424 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
241425 do {
242426 let trackers = try await fetchAllTrackers()
Hutch/Views/Inbox/InboxView.swift +6 −9
@@ -51,9 +51,9 @@ struct InboxView: View {
5151 )
5252 } else if viewModel.threads.isEmpty, viewModel.error == nil {
5353 ContentUnavailableView(
54 "No Threads",
54 "Inbox Zero",
5555 systemImage: "tray",
56 description: Text("Patch threads will appear here.")
56 description: Text("Unread threads will appear here.")
5757 )
5858 }
5959 }
@@ -75,19 +75,16 @@ struct InboxView: View {
7575 private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
7676 Button {
7777 withAnimation(.easeInOut(duration: 0.2)) {
78 viewModel.toggleThreadReadState(thread)
78 viewModel.markThreadRead(thread)
7979 }
8080 } label: {
81 Label(
82 thread.isUnread ? "Mark as Read" : "Mark as Unread",
83 systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
84 )
81 Label("Mark as Read", systemImage: "envelope.open")
8582 }
86 .tint(thread.isUnread ? .blue : .gray)
83 .tint(.blue)
8784 }
8885}
8986
90private struct InboxThreadRow: View {
87struct InboxThreadRow: View {
9188 let thread: InboxThreadSummary
9289
9390 var body: some View {
Hutch/Views/Inbox/InboxViewModel.swift +8 −2
@@ -127,7 +127,9 @@ final class InboxViewModel {
127127 let subscriptions = try await fetchSubscriptions()
128128 let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
129129 let fetchedThreads = try await fetchThreads(for: mailingLists)
130 threads = fetchedThreads.sorted { lhs, rhs in
130 threads = fetchedThreads
131 .filter(\.isUnread)
132 .sorted { lhs, rhs in
131133 if lhs.lastActivityAt == rhs.lastActivityAt {
132134 return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
133135 }
@@ -145,7 +147,7 @@ final class InboxViewModel {
145147 inboxListLogger.debug(
146148 "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
147149 )
148 updateThread(thread, isUnread: false)
150 threads.removeAll { $0.id == thread.id }
149151 }
150152
151153 func markThreadUnread(_ thread: InboxThreadSummary) {
@@ -329,6 +331,10 @@ final class InboxViewModel {
329331 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
330332 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
331333 let current = threads[index]
334 if !isUnread {
335 threads.remove(at: index)
336 return
337 }
332338 threads[index] = InboxThreadSummary(
333339 rootEmailID: current.rootEmailID,
334340 rootMessageID: current.rootMessageID,
Hutch/Views/Inbox/ThreadDetailView.swift +51 −9
@@ -8,10 +8,29 @@ private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category:
88struct ThreadDetailView: View {
99 let thread: InboxThreadSummary
1010 let onViewed: () -> Void
11 var onMarkRead: (() -> Void)? = nil
12 var onMarkUnread: (() -> Void)? = nil
1113
1214 @Environment(AppState.self) private var appState
1315 @State private var viewModel: ThreadViewModel?
1416 @State private var replySuccessMessage: String?
17 @State private var loadedThreadID: String?
18 @State private var hasMarkedCurrentThreadViewed = false
19 @State private var suppressAutoMarkViewed = false
20 @State private var isUnread: Bool
21
22 init(
23 thread: InboxThreadSummary,
24 onViewed: @escaping () -> Void,
25 onMarkRead: (() -> Void)? = nil,
26 onMarkUnread: (() -> Void)? = nil
27 ) {
28 self.thread = thread
29 self.onViewed = onViewed
30 self.onMarkRead = onMarkRead
31 self.onMarkUnread = onMarkUnread
32 self._isUnread = State(initialValue: thread.isUnread)
33 }
1534
1635 var body: some View {
1736 Group {
@@ -23,13 +42,21 @@ struct ThreadDetailView: View {
2342 }
2443 .navigationTitle("Thread")
2544 .navigationBarTitleDisplayMode(.inline)
26 .task {
27 if viewModel == nil {
28 onViewed()
29 let vm = ThreadViewModel(summary: thread, client: appState.client)
30 viewModel = vm
31 await vm.loadThread()
32 }
45 .task(id: thread.id) {
46 guard loadedThreadID != thread.id else { return }
47 let vm = ThreadViewModel(summary: thread, client: appState.client)
48 viewModel = vm
49 loadedThreadID = thread.id
50 hasMarkedCurrentThreadViewed = false
51 suppressAutoMarkViewed = false
52 isUnread = thread.isUnread
53 await vm.loadThread()
54 }
55 .onChange(of: viewModel?.thread?.id) { _, threadID in
56 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
57 hasMarkedCurrentThreadViewed = true
58 isUnread = false
59 onViewed()
3360 }
3461 .sheet(item: Binding(
3562 get: { viewModel?.composeDraft },
@@ -109,8 +136,23 @@ struct ThreadDetailView: View {
109136 .listStyle(.plain)
110137 .toolbar {
111138 ToolbarItem(placement: .topBarTrailing) {
112 Button("Reply") {
113 viewModel.prepareReply()
139 HStack {
140 if onMarkRead != nil || onMarkUnread != nil {
141 Button(isUnread ? "Mark Read" : "Mark Unread") {
142 suppressAutoMarkViewed = !isUnread
143 if isUnread {
144 onMarkRead?()
145 isUnread = false
146 } else {
147 onMarkUnread?()
148 isUnread = true
149 }
150 }
151 }
152
153 Button("Reply") {
154 viewModel.prepareReply()
155 }
114156 }
115157 }
116158 }
Hutch/Views/Lists/MailingListListView.swift added +159
@@ -0,0 +1,159 @@
1import SwiftUI
2
3@Observable
4@MainActor
5final class MailingListListViewModel {
6 private(set) var mailingLists: [InboxMailingListReference] = []
7 private(set) var isLoading = false
8 var error: String?
9
10 private let client: SRHTClient
11
12 private static let subscriptionsQuery = """
13 query mailingLists($cursor: Cursor) {
14 subscriptions(cursor: $cursor) {
15 results {
16 ... on MailingListSubscription {
17 list {
18 id
19 rid
20 name
21 owner { canonicalName }
22 }
23 }
24 }
25 cursor
26 }
27 }
28 """
29
30 init(client: SRHTClient) {
31 self.client = client
32 }
33
34 func loadMailingLists() async {
35 guard !isLoading else { return }
36 isLoading = true
37 error = nil
38 defer { isLoading = false }
39
40 do {
41 mailingLists = try await fetchMailingLists()
42 } catch {
43 self.error = "Failed to load mailing lists"
44 }
45 }
46
47 private func fetchMailingLists() async throws -> [InboxMailingListReference] {
48 struct Response: Decodable, Sendable {
49 let subscriptions: Page
50 }
51
52 struct Page: Decodable, Sendable {
53 let results: [Subscription]
54 let cursor: String?
55 }
56
57 struct Subscription: Decodable, Sendable {
58 let list: InboxMailingListReference?
59 }
60
61 var results: [InboxMailingListReference] = []
62 var cursor: String?
63
64 while true {
65 var variables: [String: any Sendable] = [:]
66 if let cursor {
67 variables["cursor"] = cursor
68 }
69
70 let response = try await client.execute(
71 service: .lists,
72 query: Self.subscriptionsQuery,
73 variables: variables.isEmpty ? nil : variables,
74 responseType: Response.self
75 )
76
77 results.append(contentsOf: response.subscriptions.results.compactMap(\.list))
78 guard let nextCursor = response.subscriptions.cursor else {
79 break
80 }
81 cursor = nextCursor
82 }
83
84 var seen = Set<String>()
85 return results
86 .filter { seen.insert($0.rid).inserted }
87 .sorted {
88 if $0.owner.canonicalName == $1.owner.canonicalName {
89 return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
90 }
91 return $0.owner.canonicalName.localizedCaseInsensitiveCompare($1.owner.canonicalName) == .orderedAscending
92 }
93 }
94}
95
96struct MailingListListView: View {
97 @Environment(AppState.self) private var appState
98 @State private var viewModel: MailingListListViewModel?
99
100 var body: some View {
101 Group {
102 if let viewModel {
103 content(viewModel)
104 } else {
105 SRHTLoadingStateView(message: "Loading mailing lists…")
106 }
107 }
108 .navigationTitle("Lists")
109 .task {
110 if viewModel == nil {
111 let vm = MailingListListViewModel(client: appState.client)
112 viewModel = vm
113 await vm.loadMailingLists()
114 }
115 }
116 }
117
118 @ViewBuilder
119 private func content(_ viewModel: MailingListListViewModel) -> some View {
120 @Bindable var vm = viewModel
121
122 List {
123 ForEach(viewModel.mailingLists, id: \.rid) { mailingList in
124 NavigationLink(value: MoreRoute.mailingList(mailingList)) {
125 VStack(alignment: .leading, spacing: 4) {
126 Text(mailingList.name)
127 .font(.subheadline.weight(.medium))
128 Text(mailingList.owner.canonicalName)
129 .font(.caption)
130 .foregroundStyle(.secondary)
131 }
132 .padding(.vertical, 2)
133 }
134 }
135 }
136 .listStyle(.plain)
137 .overlay {
138 if viewModel.isLoading, viewModel.mailingLists.isEmpty {
139 SRHTLoadingStateView(message: "Loading mailing lists…")
140 } else if let error = viewModel.error, viewModel.mailingLists.isEmpty {
141 SRHTErrorStateView(
142 title: "Couldn't Load Mailing Lists",
143 message: error,
144 retryAction: { await viewModel.loadMailingLists() }
145 )
146 } else if viewModel.mailingLists.isEmpty {
147 ContentUnavailableView(
148 "No Mailing Lists",
149 systemImage: "list.bullet.rectangle",
150 description: Text("Your subscribed mailing lists will appear here.")
151 )
152 }
153 }
154 .srhtErrorBanner(error: $vm.error)
155 .refreshable {
156 await viewModel.loadMailingLists()
157 }
158 }
159}
Hutch/Views/More/MoreView.swift added +40
@@ -0,0 +1,40 @@
1import SwiftUI
2
3struct MoreView: View {
4 private let unsupportedLinks: [(title: String, url: URL)] = [
5 ("chat.sr.ht", URL(string: "https://chat.sr.ht")!),
6 ("man.sr.ht", URL(string: "https://man.sr.ht")!),
7 ("srht.site", URL(string: "https://srht.site")!)
8 ]
9
10 var body: some View {
11 List {
12 Section {
13 NavigationLink(value: MoreRoute.lists) {
14 Label("Lists", systemImage: "list.bullet.rectangle")
15 }
16
17 NavigationLink(value: MoreRoute.pastes) {
18 Label("Pastes", systemImage: "doc.on.clipboard")
19 }
20
21 NavigationLink(value: MoreRoute.settings) {
22 Label("Settings", systemImage: "gear")
23 }
24 }
25
26 Section {
27 ForEach(unsupportedLinks, id: \.title) { item in
28 Link(destination: item.url) {
29 Label(item.title, systemImage: "safari")
30 }
31 }
32 } header: {
33 Text("External Links")
34 } footer: {
35 Text("These SourceHut services are not supported in-app and open in your browser.")
36 }
37 }
38 .navigationTitle("More")
39 }
40}
Hutch/Views/Pastes/PasteDetailView.swift added +288
@@ -0,0 +1,288 @@
1import SwiftUI
2
3struct PasteDetailView: View {
4 let paste: Paste
5 var onUpdated: ((Paste) -> Void)? = nil
6 var onDeleted: ((String) -> Void)? = nil
7
8 @Environment(AppState.self) private var appState
9 @Environment(\.dismiss) private var dismiss
10 @State private var viewModel: PasteDetailViewModel?
11 @State private var showVisibilitySheet = false
12 @State private var showDeleteConfirmation = false
13
14 var body: some View {
15 Group {
16 if let viewModel {
17 content(viewModel)
18 } else {
19 SRHTLoadingStateView(message: "Loading paste…")
20 }
21 }
22 .navigationTitle(displayTitle)
23 .navigationBarTitleDisplayMode(.inline)
24 .toolbar {
25 ToolbarItemGroup(placement: .topBarTrailing) {
26 SRHTShareButton(
27 url: currentPaste.flatMap { SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) },
28 target: .paste
29 ) {
30 Image(systemName: "square.and.arrow.up")
31 }
32
33 if viewModel != nil {
34 Menu {
35 Button {
36 showVisibilitySheet = true
37 } label: {
38 Label("Change Visibility", systemImage: "eye")
39 }
40
41 Button(role: .destructive) {
42 showDeleteConfirmation = true
43 } label: {
44 Label("Delete Paste", systemImage: "trash")
45 }
46 } label: {
47 Image(systemName: "ellipsis.circle")
48 }
49 }
50 }
51 }
52 .sheet(isPresented: $showVisibilitySheet) {
53 if let viewModel, let currentPaste {
54 PasteVisibilitySheet(
55 currentVisibility: currentPaste.visibility,
56 isUpdating: viewModel.isUpdatingVisibility
57 ) { visibility in
58 if let updated = await viewModel.updateVisibility(visibility) {
59 onUpdated?(updated)
60 showVisibilitySheet = false
61 }
62 }
63 }
64 }
65 .alert("Delete Paste?", isPresented: $showDeleteConfirmation) {
66 Button("Cancel", role: .cancel) {}
67 Button("Delete", role: .destructive) {
68 Task {
69 if await viewModel?.deletePaste() == true {
70 onDeleted?(paste.id)
71 dismiss()
72 }
73 }
74 }
75 } message: {
76 Text("This paste will be permanently removed.")
77 }
78 .task {
79 if viewModel == nil {
80 let vm = PasteDetailViewModel(
81 pasteID: paste.id,
82 initialPaste: paste,
83 service: PasteService(client: appState.client)
84 )
85 viewModel = vm
86 await vm.loadPaste()
87 }
88 }
89 }
90
91 private var currentPaste: Paste? {
92 viewModel?.paste ?? paste
93 }
94
95 private var displayTitle: String {
96 if let filename = currentPaste?.files.first?.filename, !filename.isEmpty {
97 return filename
98 }
99 return "Paste \(paste.id)"
100 }
101
102 @ViewBuilder
103 private func content(_ viewModel: PasteDetailViewModel) -> some View {
104 @Bindable var vm = viewModel
105
106 if viewModel.isLoading, viewModel.paste == nil {
107 SRHTLoadingStateView(message: "Loading paste…")
108 } else if let error = viewModel.error, viewModel.paste == nil {
109 SRHTErrorStateView(
110 title: "Couldn't Load Paste",
111 message: error,
112 retryAction: { await viewModel.loadPaste() }
113 )
114 } else if let paste = viewModel.paste {
115 List {
116 Section("Details") {
117 LabeledContent("ID", value: paste.id)
118 LabeledContent("Owner", value: paste.user.canonicalName)
119 LabeledContent("Created", value: paste.created.relativeDescription)
120 LabeledContent("Visibility", value: visibilityLabel(paste.visibility))
121 LabeledContent("Files", value: "\(paste.files.count)")
122 }
123
124 if paste.files.count > 1 {
125 Section("Files") {
126 Picker("Selected File", selection: Binding(
127 get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
128 set: { viewModel.selectFile(hash: $0) }
129 )) {
130 ForEach(paste.files) { file in
131 Text(file.filename ?? String(file.hash.prefix(8)))
132 .tag(file.hash)
133 }
134 }
135 }
136 }
137
138 if let file = viewModel.selectedFile {
139 Section("Current File") {
140 if let filename = file.filename, !filename.isEmpty {
141 LabeledContent("Filename", value: filename)
142 }
143 LabeledContent("Hash", value: file.hash)
144 }
145
146 Section {
147 if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
148 SRHTLoadingStateView(message: "Loading paste contents…")
149 .frame(minHeight: 180)
150 } else if let contents = viewModel.selectedFileContents {
151 PasteCodeBlock(text: contents)
152 } else {
153 Text("This file’s contents are unavailable.")
154 .foregroundStyle(.secondary)
155 }
156 } header: {
157 Text("Contents")
158 }
159 }
160 }
161 .listStyle(.insetGrouped)
162 .srhtErrorBanner(error: $vm.error)
163 .refreshable {
164 await viewModel.loadPaste()
165 }
166 }
167 }
168
169 private func visibilityLabel(_ visibility: Visibility) -> String {
170 switch visibility {
171 case .public:
172 return "Public"
173 case .unlisted:
174 return "Unlisted"
175 case .private:
176 return "Private"
177 }
178 }
179}
180
181private struct PasteCodeBlock: View {
182 let text: String
183
184 var body: some View {
185 ScrollView([.horizontal, .vertical], showsIndicators: true) {
186 Text(text.isEmpty ? " " : text)
187 .font(.system(.body, design: .monospaced))
188 .textSelection(.enabled)
189 .frame(maxWidth: .infinity, alignment: .leading)
190 .padding(.vertical, 4)
191 }
192 .frame(minHeight: 220)
193 }
194}
195
196private struct PasteVisibilitySheet: View {
197 let currentVisibility: Visibility
198 let isUpdating: Bool
199 let onSave: (Visibility) async -> Void
200
201 @Environment(\.dismiss) private var dismiss
202 @State private var visibility: Visibility
203
204 init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) {
205 self.currentVisibility = currentVisibility
206 self.isUpdating = isUpdating
207 self.onSave = onSave
208 _visibility = State(initialValue: currentVisibility)
209 }
210
211 var body: some View {
212 NavigationStack {
213 List {
214 ForEach(visibilityOptions, id: \.self) { option in
215 Button {
216 visibility = option
217 } label: {
218 HStack {
219 VStack(alignment: .leading, spacing: 2) {
220 Text(title(for: option))
221 .foregroundStyle(.primary)
222 Text(description(for: option))
223 .font(.caption)
224 .foregroundStyle(.secondary)
225 }
226
227 Spacer()
228
229 if visibility == option {
230 Image(systemName: "checkmark")
231 .foregroundStyle(.tint)
232 }
233 }
234 .contentShape(Rectangle())
235 }
236 .buttonStyle(.plain)
237 }
238 }
239 .listStyle(.insetGrouped)
240 .navigationTitle("Visibility")
241 .navigationBarTitleDisplayMode(.inline)
242 .toolbar {
243 ToolbarItem(placement: .cancellationAction) {
244 Button("Cancel") { dismiss() }
245 }
246 ToolbarItem(placement: .confirmationAction) {
247 Button("Save") {
248 Task {
249 await onSave(visibility)
250 }
251 }
252 .disabled(isUpdating || visibility == currentVisibility)
253 }
254 }
255 .overlay {
256 if isUpdating {
257 ProgressView()
258 }
259 }
260 }
261 }
262
263 private var visibilityOptions: [Visibility] {
264 [.public, .unlisted, .private]
265 }
266
267 private func title(for visibility: Visibility) -> String {
268 switch visibility {
269 case .public:
270 "Public"
271 case .unlisted:
272 "Unlisted"
273 case .private:
274 "Private"
275 }
276 }
277
278 private func description(for visibility: Visibility) -> String {
279 switch visibility {
280 case .public:
281 "Visible to everyone and listed on your profile."
282 case .unlisted:
283 "Visible to anyone with the URL, but not listed on your profile."
284 case .private:
285 "Visible only to explicitly allowed viewers."
286 }
287 }
288}
Hutch/Views/Pastes/PasteDetailViewModel.swift added +114
@@ -0,0 +1,114 @@
1import Foundation
2
3@Observable
4@MainActor
5final class PasteDetailViewModel {
6 private(set) var paste: Paste?
7 private(set) var isLoading = false
8 private(set) var isUpdatingVisibility = false
9 private(set) var isDeleting = false
10 private(set) var loadingFileHashes: Set<String> = []
11 var error: String?
12
13 var selectedFileHash: String?
14 private(set) var fileContents: [String: String] = [:]
15
16 private let pasteID: String
17 private let service: PasteService
18
19 init(pasteID: String, initialPaste: Paste? = nil, service: PasteService) {
20 self.pasteID = pasteID
21 self.paste = initialPaste
22 self.service = service
23 self.selectedFileHash = initialPaste?.files.first?.hash
24 }
25
26 var selectedFile: PasteFile? {
27 let hash = selectedFileHash ?? paste?.files.first?.hash
28 return paste?.files.first(where: { $0.hash == hash })
29 }
30
31 var selectedFileContents: String? {
32 guard let selectedFile else { return nil }
33 return fileContents[selectedFile.hash]
34 }
35
36 func loadPaste() async {
37 guard !isLoading else { return }
38 isLoading = true
39 error = nil
40 defer { isLoading = false }
41
42 do {
43 let loaded = try await service.loadPaste(id: pasteID)
44 paste = loaded
45 if selectedFileHash == nil {
46 selectedFileHash = loaded?.files.first?.hash
47 }
48 await loadSelectedFileContentsIfNeeded()
49 } catch {
50 self.error = error.localizedDescription
51 }
52 }
53
54 func selectFile(hash: String) {
55 selectedFileHash = hash
56 Task {
57 await loadSelectedFileContentsIfNeeded()
58 }
59 }
60
61 func updateVisibility(_ visibility: Visibility) async -> Paste? {
62 guard !isUpdatingVisibility else { return nil }
63 guard let paste else { return nil }
64 guard paste.visibility != visibility else { return paste }
65
66 isUpdatingVisibility = true
67 error = nil
68 defer { isUpdatingVisibility = false }
69
70 do {
71 let updatedPaste = try await service.updateVisibility(id: paste.id, visibility: visibility)
72 if let updatedPaste {
73 self.paste = updatedPaste
74 if selectedFileHash == nil {
75 selectedFileHash = updatedPaste.files.first?.hash
76 }
77 }
78 return updatedPaste
79 } catch {
80 self.error = error.localizedDescription
81 return nil
82 }
83 }
84
85 func deletePaste() async -> Bool {
86 guard !isDeleting else { return false }
87 isDeleting = true
88 error = nil
89 defer { isDeleting = false }
90
91 do {
92 _ = try await service.deletePaste(id: pasteID)
93 return true
94 } catch {
95 self.error = error.localizedDescription
96 return false
97 }
98 }
99
100 func loadSelectedFileContentsIfNeeded() async {
101 guard let file = selectedFile, fileContents[file.hash] == nil else { return }
102 guard let url = file.contents else { return }
103 guard !loadingFileHashes.contains(file.hash) else { return }
104
105 loadingFileHashes.insert(file.hash)
106 defer { loadingFileHashes.remove(file.hash) }
107
108 do {
109 fileContents[file.hash] = try await service.loadContents(from: url)
110 } catch {
111 self.error = error.localizedDescription
112 }
113 }
114}
Hutch/Views/Pastes/PasteListView.swift added +280
@@ -0,0 +1,280 @@
1import SwiftUI
2
3struct PasteListView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: PasteListViewModel?
6 @State private var showCreatePasteSheet = false
7 @State private var createdPaste: Paste?
8
9 var body: some View {
10 Group {
11 if let viewModel {
12 content(viewModel)
13 } else {
14 SRHTLoadingStateView(message: "Loading pastes…")
15 }
16 }
17 .navigationTitle("Pastes")
18 .toolbar {
19 if viewModel != nil {
20 ToolbarItem(placement: .topBarTrailing) {
21 Button {
22 showCreatePasteSheet = true
23 } label: {
24 Image(systemName: "plus")
25 }
26 }
27 }
28 }
29 .sheet(isPresented: $showCreatePasteSheet) {
30 if let viewModel {
31 CreatePasteSheet(viewModel: viewModel) { paste in
32 showCreatePasteSheet = false
33 createdPaste = paste
34 }
35 }
36 }
37 .navigationDestination(isPresented: Binding(
38 get: { createdPaste != nil },
39 set: { isPresented in
40 if !isPresented {
41 createdPaste = nil
42 }
43 }
44 )) {
45 if let createdPaste {
46 PasteDetailView(
47 paste: createdPaste,
48 onUpdated: { updated in
49 viewModel?.upsertPaste(updated)
50 },
51 onDeleted: { id in
52 viewModel?.removePaste(id: id)
53 }
54 )
55 }
56 }
57 .task {
58 if viewModel == nil {
59 let vm = PasteListViewModel(service: PasteService(client: appState.client))
60 viewModel = vm
61 await vm.loadPastes()
62 }
63 }
64 }
65
66 @ViewBuilder
67 private func content(_ viewModel: PasteListViewModel) -> some View {
68 @Bindable var vm = viewModel
69
70 List {
71 ForEach(viewModel.pastes) { paste in
72 NavigationLink(value: paste) {
73 PasteRowView(paste: paste)
74 }
75 .task {
76 await viewModel.loadMoreIfNeeded(currentItem: paste)
77 }
78 }
79
80 if viewModel.isLoadingMore {
81 HStack {
82 Spacer()
83 ProgressView()
84 Spacer()
85 }
86 .listRowSeparator(.hidden)
87 }
88 }
89 .listStyle(.plain)
90 .overlay {
91 if viewModel.isLoading, viewModel.pastes.isEmpty {
92 SRHTLoadingStateView(message: "Loading pastes…")
93 } else if let error = viewModel.error, viewModel.pastes.isEmpty {
94 SRHTErrorStateView(
95 title: "Couldn't Load Pastes",
96 message: error,
97 retryAction: { await viewModel.loadPastes() }
98 )
99 } else if viewModel.pastes.isEmpty {
100 ContentUnavailableView(
101 "No Pastes",
102 systemImage: "doc.on.clipboard",
103 description: Text("Your pastes will appear here.")
104 )
105 }
106 }
107 .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) {
108 await viewModel.loadPastes()
109 }
110 .srhtErrorBanner(error: $vm.error)
111 .refreshable {
112 await viewModel.loadPastes()
113 }
114 .navigationDestination(for: Paste.self) { paste in
115 PasteDetailView(
116 paste: paste,
117 onUpdated: { updated in
118 viewModel.upsertPaste(updated)
119 },
120 onDeleted: { id in
121 viewModel.removePaste(id: id)
122 }
123 )
124 }
125 }
126}
127
128private struct PasteRowView: View {
129 let paste: Paste
130
131 var body: some View {
132 HStack(alignment: .top, spacing: 12) {
133 Image(systemName: "doc.text")
134 .foregroundStyle(.secondary)
135 .frame(width: 20)
136
137 VStack(alignment: .leading, spacing: 4) {
138 Text(primaryTitle)
139 .font(.subheadline.weight(.medium))
140 .lineLimit(1)
141
142 Text(secondaryLine)
143 .font(.caption)
144 .foregroundStyle(.secondary)
145 .lineLimit(2)
146
147 HStack(spacing: 8) {
148 VisibilityBadge(visibility: paste.visibility)
149 Text("•")
150 .foregroundStyle(.tertiary)
151 Text(paste.created.relativeDescription)
152 .foregroundStyle(.tertiary)
153 }
154 .font(.caption2)
155 }
156 }
157 .padding(.vertical, 2)
158 }
159
160 private var primaryTitle: String {
161 if let filename = paste.files.first?.filename, !filename.isEmpty {
162 return filename
163 }
164 return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste"
165 }
166
167 private var secondaryLine: String {
168 var parts: [String] = [paste.user.canonicalName]
169 if paste.files.count > 1 {
170 parts.append("\(paste.files.count) files")
171 } else {
172 parts.append("1 file")
173 }
174 if let firstHash = paste.files.first?.hash {
175 parts.append(String(firstHash.prefix(8)))
176 }
177 return parts.joined(separator: " • ")
178 }
179}
180
181private struct CreatePasteSheet: View {
182 let viewModel: PasteListViewModel
183 let onCreated: (Paste) -> Void
184
185 @Environment(\.dismiss) private var dismiss
186 @State private var files = [PasteUploadDraft()]
187 @State private var visibility: Visibility = .unlisted
188
189 var body: some View {
190 NavigationStack {
191 Form {
192 Section("Files") {
193 ForEach($files) { $file in
194 VStack(alignment: .leading, spacing: 8) {
195 TextField("Filename (optional)", text: $file.filename)
196 .autocorrectionDisabled()
197 .textInputAutocapitalization(.never)
198
199 ZStack(alignment: .topLeading) {
200 if file.contents.isEmpty {
201 Text("Paste contents")
202 .foregroundStyle(.tertiary)
203 .padding(.top, 8)
204 .padding(.leading, 5)
205 .allowsHitTesting(false)
206 }
207
208 TextEditor(text: $file.contents)
209 .font(.system(.body, design: .monospaced))
210 .frame(minHeight: 180)
211 }
212 }
213 .padding(.vertical, 4)
214 }
215 .onDelete { offsets in
216 files.remove(atOffsets: offsets)
217 if files.isEmpty {
218 files = [PasteUploadDraft()]
219 }
220 }
221
222 Button {
223 files.append(PasteUploadDraft())
224 } label: {
225 Label("Add File", systemImage: "plus")
226 }
227 }
228
229 Section("Visibility") {
230 Picker("Visibility", selection: $visibility) {
231 Text("Public").tag(Visibility.public)
232 Text("Unlisted").tag(Visibility.unlisted)
233 Text("Private").tag(Visibility.private)
234 }
235 }
236
237 Section {
238 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.")
239 .font(.footnote)
240 .foregroundStyle(.secondary)
241 }
242
243 if let error = viewModel.error {
244 Section {
245 Label(error, systemImage: "exclamationmark.triangle.fill")
246 .foregroundStyle(.red)
247 }
248 }
249 }
250 .navigationTitle("New Paste")
251 .navigationBarTitleDisplayMode(.inline)
252 .toolbar {
253 ToolbarItem(placement: .cancellationAction) {
254 Button("Cancel") { dismiss() }
255 }
256 ToolbarItem(placement: .confirmationAction) {
257 Button {
258 Task {
259 if let paste = await viewModel.createPaste(files: files, visibility: visibility) {
260 onCreated(paste)
261 }
262 }
263 } label: {
264 if viewModel.isCreatingPaste {
265 ProgressView()
266 .controlSize(.small)
267 } else {
268 Text("Create Paste")
269 }
270 }
271 .disabled(!hasValidContent || viewModel.isCreatingPaste)
272 }
273 }
274 }
275 }
276
277 private var hasValidContent: Bool {
278 files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
279 }
280}
Hutch/Views/Pastes/PasteListViewModel.swift added +109
@@ -0,0 +1,109 @@
1import Foundation
2
3@Observable
4@MainActor
5final class PasteListViewModel {
6 private(set) var pastes: [Paste] = []
7 private(set) var isLoading = false
8 private(set) var isLoadingMore = false
9 private(set) var isRefreshing = false
10 private(set) var isCreatingPaste = false
11 var error: String?
12
13 private var cursor: String?
14 private var hasMore = true
15 private let service: PasteService
16
17 init(service: PasteService) {
18 self.service = service
19 }
20
21 func loadPastes() async {
22 if pastes.isEmpty, let cached = service.loadCachedPastes() {
23 pastes = cached.results
24 cursor = cached.cursor
25 hasMore = cached.cursor != nil
26 }
27
28 if pastes.isEmpty {
29 isLoading = true
30 } else {
31 isRefreshing = true
32 }
33 error = nil
34 cursor = nil
35 hasMore = true
36
37 do {
38 let page = try await service.listPastes(cursor: nil, useCache: true)
39 pastes = page.results
40 cursor = page.cursor
41 hasMore = page.cursor != nil
42 } catch {
43 if pastes.isEmpty {
44 self.error = error.localizedDescription
45 }
46 }
47
48 isLoading = false
49 isRefreshing = false
50 }
51
52 func loadMoreIfNeeded(currentItem: Paste) async {
53 guard let last = pastes.last,
54 last.id == currentItem.id,
55 hasMore,
56 !isLoadingMore else {
57 return
58 }
59
60 isLoadingMore = true
61 defer { isLoadingMore = false }
62
63 do {
64 let page = try await service.listPastes(cursor: cursor, useCache: false)
65 pastes.append(contentsOf: page.results)
66 cursor = page.cursor
67 hasMore = page.cursor != nil
68 } catch {
69 self.error = error.localizedDescription
70 }
71 }
72
73 func createPaste(files: [PasteUploadDraft], visibility: Visibility) async -> Paste? {
74 guard !isCreatingPaste else { return nil }
75
76 let normalizedFiles = files.filter {
77 !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
78 }
79 guard !normalizedFiles.isEmpty else {
80 error = "Add at least one file with text content."
81 return nil
82 }
83
84 isCreatingPaste = true
85 error = nil
86 defer { isCreatingPaste = false }
87
88 do {
89 let paste = try await service.createPaste(files: normalizedFiles, visibility: visibility)
90 upsertPaste(paste)
91 return paste
92 } catch {
93 self.error = error.localizedDescription
94 return nil
95 }
96 }
97
98 func upsertPaste(_ paste: Paste) {
99 if let index = pastes.firstIndex(where: { $0.id == paste.id }) {
100 pastes[index] = paste
101 } else {
102 pastes.insert(paste, at: 0)
103 }
104 }
105
106 func removePaste(id: String) {
107 pastes.removeAll { $0.id == id }
108 }
109}
Hutch/Views/Projects/ProjectDetailView.swift added +135
@@ -0,0 +1,135 @@
1import SwiftUI
2
3struct ProjectDetailView: View {
4 let project: Project
5 @Environment(AppState.self) private var appState
6 @Environment(\.dismiss) private var dismiss
7
8 var body: some View {
9 List {
10 headerSection
11 repositoriesSection
12 trackersSection
13 mailingListsSection
14 }
15 .navigationTitle(project.name)
16 .navigationBarTitleDisplayMode(.inline)
17 }
18
19 @ViewBuilder
20 private var headerSection: some View {
21 Section {
22 VStack(alignment: .leading, spacing: 8) {
23 Text(project.name)
24 .font(.headline)
25
26 if let description = project.description, !description.isEmpty {
27 Text(description)
28 .font(.subheadline)
29 .foregroundStyle(.secondary)
30 }
31
32 if let website = project.website, let url = URL(string: website) {
33 Link(destination: url) {
34 Label(website, systemImage: "link")
35 .font(.subheadline)
36 }
37 }
38 }
39 .padding(.vertical, 4)
40 }
41 }
42
43 @ViewBuilder
44 private var repositoriesSection: some View {
45 if !project.sources.isEmpty {
46 Section("Repositories") {
47 ForEach(project.sources) { source in
48 Button {
49 Task {
50 try? await appState.openProjectSource(source)
51 dismiss()
52 }
53 } label: {
54 ProjectResourceRow(
55 title: source.name,
56 subtitle: source.owner.canonicalName,
57 detail: source.description
58 )
59 }
60 .buttonStyle(.plain)
61 }
62 }
63 }
64 }
65
66 @ViewBuilder
67 private var trackersSection: some View {
68 if !project.trackers.isEmpty {
69 Section("Trackers") {
70 ForEach(project.trackers) { tracker in
71 Button {
72 Task {
73 try? await appState.openProjectTracker(tracker)
74 dismiss()
75 }
76 } label: {
77 ProjectResourceRow(
78 title: tracker.name,
79 subtitle: tracker.owner.canonicalName,
80 detail: tracker.description
81 )
82 }
83 .buttonStyle(.plain)
84 }
85 }
86 }
87 }
88
89 @ViewBuilder
90 private var mailingListsSection: some View {
91 if !project.mailingLists.isEmpty {
92 Section("Mailing Lists") {
93 ForEach(project.mailingLists) { mailingList in
94 Button {
95 appState.openMailingList(mailingList.inboxReference)
96 dismiss()
97 } label: {
98 ProjectResourceRow(
99 title: mailingList.name,
100 subtitle: mailingList.owner.canonicalName,
101 detail: mailingList.description
102 )
103 }
104 .buttonStyle(.plain)
105 }
106 }
107 }
108 }
109}
110
111private struct ProjectResourceRow: View {
112 let title: String
113 let subtitle: String
114 let detail: String?
115
116 var body: some View {
117 VStack(alignment: .leading, spacing: 4) {
118 Text(title)
119 .font(.subheadline.weight(.medium))
120
121 Text(subtitle)
122 .font(.caption)
123 .foregroundStyle(.secondary)
124 .lineLimit(1)
125
126 if let detail, !detail.isEmpty {
127 Text(detail)
128 .font(.caption)
129 .foregroundStyle(.tertiary)
130 .lineLimit(2)
131 }
132 }
133 .padding(.vertical, 2)
134 }
135}
Hutch/Views/Projects/ProjectMailingListView.swift added +282
@@ -0,0 +1,282 @@
1import SwiftUI
2
3private struct ProjectMailingListThreadsResponse: Decodable, Sendable {
4 let list: ProjectMailingListThreads
5}
6
7private struct ProjectMailingListThreads: Decodable, Sendable {
8 let threads: ProjectMailingListThreadPage
9}
10
11private struct ProjectMailingListThreadPage: Decodable, Sendable {
12 let results: [ProjectMailingListThreadPayload]
13}
14
15private struct ProjectMailingListThreadPayload: Decodable, Sendable {
16 let updated: Date
17 let subject: String
18 let replies: Int
19 let sender: Entity
20 let root: ProjectMailingListRootPayload
21}
22
23private struct ProjectMailingListRootPayload: Decodable, Sendable {
24 let id: Int
25 let messageID: String
26 let patch: InboxPatchPreview?
27}
28
29@Observable
30@MainActor
31final class MailingListDetailViewModel {
32 private(set) var threads: [InboxThreadSummary] = []
33 private(set) var isLoading = false
34 var error: String?
35
36 private let mailingList: InboxMailingListReference
37 private let client: SRHTClient
38
39 private static let listThreadsQuery = """
40 query projectMailingListThreads($rid: ID!) {
41 list(rid: $rid) {
42 threads {
43 results {
44 updated
45 subject
46 replies
47 sender { canonicalName }
48 root {
49 id
50 messageID
51 patch { subject }
52 }
53 }
54 }
55 }
56 }
57 """
58
59 init(mailingList: InboxMailingListReference, client: SRHTClient) {
60 self.mailingList = mailingList
61 self.client = client
62 }
63
64 func loadThreads() async {
65 guard !isLoading else { return }
66 isLoading = true
67 error = nil
68 defer { isLoading = false }
69
70 do {
71 let response = try await client.execute(
72 service: .lists,
73 query: Self.listThreadsQuery,
74 variables: ["rid": mailingList.rid],
75 responseType: ProjectMailingListThreadsResponse.self
76 )
77
78 threads = deduplicateThreads(
79 response.list.threads.results.map(makeSummary(from:))
80 )
81 } catch {
82 self.error = "Failed to load mailing list"
83 }
84 }
85
86 func markThreadRead(_ thread: InboxThreadSummary) {
87 let viewedAt = max(Date(), thread.lastActivityAt)
88 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
89 updateThread(thread, isUnread: false)
90 }
91
92 func markThreadUnread(_ thread: InboxThreadSummary) {
93 InboxReadStateStore.markUnread(for: thread.id)
94 updateThread(thread, isUnread: true)
95 }
96
97 private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
98 let normalizedSubject = thread.subject
99 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
100 .trimmingCharacters(in: .whitespacesAndNewlines)
101 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
102 .lowercased()
103 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
104
105 return InboxThreadSummary(
106 rootEmailID: thread.root.id,
107 rootMessageID: thread.root.messageID,
108 threadRootEmailIDs: [thread.root.id],
109 threadRootMessageIDs: [thread.root.messageID],
110 listID: 0,
111 listRID: mailingList.rid,
112 listName: mailingList.name,
113 listOwner: mailingList.owner,
114 subject: thread.subject,
115 latestSender: thread.sender,
116 lastActivityAt: thread.updated,
117 messageCount: thread.replies + 1,
118 repo: nil,
119 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
120 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
121 )
122 }
123
124 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
125 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
126 let current = threads[index]
127 threads[index] = InboxThreadSummary(
128 rootEmailID: current.rootEmailID,
129 rootMessageID: current.rootMessageID,
130 threadRootEmailIDs: current.threadRootEmailIDs,
131 threadRootMessageIDs: current.threadRootMessageIDs,
132 listID: current.listID,
133 listRID: current.listRID,
134 listName: current.listName,
135 listOwner: current.listOwner,
136 subject: current.subject,
137 latestSender: current.latestSender,
138 lastActivityAt: current.lastActivityAt,
139 messageCount: current.messageCount,
140 repo: current.repo,
141 containsPatch: current.containsPatch,
142 isUnread: isUnread
143 )
144 }
145
146 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
147 var grouped: [String: InboxThreadSummary] = [:]
148
149 for thread in threads {
150 guard let existing = grouped[thread.threadGroupingKey] else {
151 grouped[thread.threadGroupingKey] = thread
152 continue
153 }
154
155 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
156 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
157 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
158 let mergedMessageCount = max(
159 existing.messageCount ?? existing.threadRootMessageIDs.count,
160 thread.messageCount ?? thread.threadRootMessageIDs.count,
161 mergedRootMessageIDs.count
162 )
163
164 grouped[thread.threadGroupingKey] = InboxThreadSummary(
165 rootEmailID: latest.rootEmailID,
166 rootMessageID: latest.rootMessageID,
167 threadRootEmailIDs: mergedRootEmailIDs,
168 threadRootMessageIDs: mergedRootMessageIDs,
169 listID: latest.listID,
170 listRID: latest.listRID,
171 listName: latest.listName,
172 listOwner: latest.listOwner,
173 subject: latest.subject,
174 latestSender: latest.latestSender,
175 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
176 messageCount: mergedMessageCount,
177 repo: latest.repo ?? existing.repo,
178 containsPatch: latest.containsPatch || existing.containsPatch,
179 isUnread: latest.isUnread || existing.isUnread
180 )
181 }
182
183 return grouped.values.sorted { lhs, rhs in
184 if lhs.lastActivityAt == rhs.lastActivityAt {
185 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
186 }
187 return lhs.lastActivityAt > rhs.lastActivityAt
188 }
189 }
190}
191
192struct MailingListDetailView: View {
193 let mailingList: InboxMailingListReference
194
195 @Environment(AppState.self) private var appState
196 @State private var viewModel: MailingListDetailViewModel?
197
198 var body: some View {
199 Group {
200 if let viewModel {
201 content(viewModel)
202 } else {
203 SRHTLoadingStateView(message: "Loading mailing list…")
204 }
205 }
206 .navigationTitle(mailingList.name)
207 .navigationBarTitleDisplayMode(.inline)
208 .task {
209 if viewModel == nil {
210 let viewModel = MailingListDetailViewModel(mailingList: mailingList, client: appState.client)
211 self.viewModel = viewModel
212 await viewModel.loadThreads()
213 }
214 }
215 .onAppear {
216 guard let viewModel else { return }
217 Task {
218 await viewModel.loadThreads()
219 }
220 }
221 }
222
223 @ViewBuilder
224 private func content(_ viewModel: MailingListDetailViewModel) -> some View {
225 @Bindable var vm = viewModel
226
227 List {
228 ForEach(viewModel.threads) { thread in
229 NavigationLink(value: MoreRoute.thread(thread)) {
230 InboxThreadRow(thread: thread)
231 }
232 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
233 Button {
234 withAnimation(.easeInOut(duration: 0.2)) {
235 if thread.isUnread {
236 viewModel.markThreadRead(thread)
237 } else {
238 viewModel.markThreadUnread(thread)
239 }
240 }
241 } label: {
242 Label(
243 thread.isUnread ? "Mark as Read" : "Mark as Unread",
244 systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
245 )
246 }
247 .tint(thread.isUnread ? .blue : .gray)
248 }
249 }
250 }
251 .listStyle(.plain)
252 .overlay {
253 if viewModel.isLoading, viewModel.threads.isEmpty {
254 SRHTLoadingStateView(message: "Loading mailing list…")
255 } else if let error = viewModel.error, viewModel.threads.isEmpty {
256 SRHTErrorStateView(
257 title: "Couldn't Load Mailing List",
258 message: error,
259 retryAction: { await viewModel.loadThreads() }
260 )
261 } else if viewModel.threads.isEmpty {
262 ContentUnavailableView(
263 "No Threads",
264 systemImage: "tray",
265 description: Text("This mailing list does not have any recent threads.")
266 )
267 }
268 }
269 .refreshable {
270 await viewModel.loadThreads()
271 }
272 .srhtErrorBanner(error: $vm.error)
273 }
274}
275
276struct ProjectMailingListView: View {
277 let mailingList: Project.MailingList
278
279 var body: some View {
280 MailingListDetailView(mailingList: mailingList.inboxReference)
281 }
282}
Hutch/Views/Settings/SettingsView.swift +12 −14
@@ -12,21 +12,19 @@ struct SettingsView: View {
1212 @State private var pendingDestructiveAction: SettingsDestructiveAction?
1313
1414 var body: some View {
15 NavigationStack {
16 Group {
17 if let viewModel {
18 settingsContent(viewModel)
19 } else {
20 SRHTLoadingStateView(message: "Loading profile…")
21 }
15 Group {
16 if let viewModel {
17 settingsContent(viewModel)
18 } else {
19 SRHTLoadingStateView(message: "Loading profile…")
2220 }
23 .navigationTitle("Settings")
24 .task {
25 if viewModel == nil {
26 let vm = SettingsViewModel(client: appState.client)
27 viewModel = vm
28 await vm.loadProfile()
29 }
21 }
22 .navigationTitle("Settings")
23 .task {
24 if viewModel == nil {
25 let vm = SettingsViewModel(client: appState.client)
26 viewModel = vm
27 await vm.loadProfile()
3028 }
3129 }
3230 }
HutchTests/ProjectTests.swift added +72
@@ -0,0 +1,72 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct ProjectTests {
6 @Test
7 func resourceSummaryIncludesCounts() {
8 let project = Project(
9 id: "project-1",
10 name: "Hutch",
11 description: nil,
12 website: nil,
13 visibility: .public,
14 tags: [],
15 mailingLists: [
16 Project.MailingList(
17 id: "list-1",
18 name: "hutch-devel",
19 description: nil,
20 visibility: .public,
21 owner: Entity(canonicalName: "~owner")
22 )
23 ],
24 sources: [
25 Project.SourceRepo(
26 id: "repo-1",
27 name: "hutch",
28 description: nil,
29 visibility: .public,
30 owner: Entity(canonicalName: "~owner"),
31 repoType: .git
32 ),
33 Project.SourceRepo(
34 id: "repo-2",
35 name: "hutch-web",
36 description: nil,
37 visibility: .public,
38 owner: Entity(canonicalName: "~owner"),
39 repoType: .git
40 )
41 ],
42 trackers: [
43 Project.Tracker(
44 id: "tracker-1",
45 name: "bugs",
46 description: nil,
47 visibility: .public,
48 owner: Entity(canonicalName: "~owner")
49 )
50 ]
51 )
52
53 #expect(project.resourceSummary == "2 repos • 1 tracker • 1 list")
54 }
55
56 @Test
57 func resourceSummaryFallsBackToWebsite() {
58 let project = Project(
59 id: "project-1",
60 name: "Docs",
61 description: nil,
62 website: "https://example.com",
63 visibility: .public,
64 tags: [],
65 mailingLists: [],
66 sources: [],
67 trackers: []
68 )
69
70 #expect(project.resourceSummary == "Website linked")
71 }
72}