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 @@
363 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 363 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
364 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 364 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
365 CODE_SIGN_STYLE = Automatic; 365 CODE_SIGN_STYLE = Automatic;
366 CURRENT_PROJECT_VERSION = 2; 366 CURRENT_PROJECT_VERSION = 5;
367 DEVELOPMENT_TEAM = ZCNAX3VL9D; 367 DEVELOPMENT_TEAM = ZCNAX3VL9D;
368 ENABLE_PREVIEWS = YES; 368 ENABLE_PREVIEWS = YES;
369 GENERATE_INFOPLIST_FILE = YES; 369 GENERATE_INFOPLIST_FILE = YES;
@@ -380,7 +380,7 @@
380 "$(inherited)", 380 "$(inherited)",
381 "@executable_path/Frameworks", 381 "@executable_path/Frameworks",
382 ); 382 );
383 MARKETING_VERSION = 1.2; 383 MARKETING_VERSION = 2.0;
384 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 384 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
385 PRODUCT_NAME = "$(TARGET_NAME)"; 385 PRODUCT_NAME = "$(TARGET_NAME)";
386 STRING_CATALOG_GENERATE_SYMBOLS = YES; 386 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -399,7 +399,7 @@
399 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 399 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
400 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 400 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
401 CODE_SIGN_STYLE = Automatic; 401 CODE_SIGN_STYLE = Automatic;
402 CURRENT_PROJECT_VERSION = 2; 402 CURRENT_PROJECT_VERSION = 5;
403 DEVELOPMENT_TEAM = ZCNAX3VL9D; 403 DEVELOPMENT_TEAM = ZCNAX3VL9D;
404 ENABLE_PREVIEWS = YES; 404 ENABLE_PREVIEWS = YES;
405 GENERATE_INFOPLIST_FILE = YES; 405 GENERATE_INFOPLIST_FILE = YES;
@@ -416,7 +416,7 @@
416 "$(inherited)", 416 "$(inherited)",
417 "@executable_path/Frameworks", 417 "@executable_path/Frameworks",
418 ); 418 );
419 MARKETING_VERSION = 1.2; 419 MARKETING_VERSION = 2.0;
420 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 420 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
421 PRODUCT_NAME = "$(TARGET_NAME)"; 421 PRODUCT_NAME = "$(TARGET_NAME)";
422 STRING_CATALOG_GENERATE_SYMBOLS = YES; 422 STRING_CATALOG_GENERATE_SYMBOLS = YES;
Hutch/App/AppState.swift +41 −5
@@ -9,11 +9,16 @@ final class AppState {
9 9
10 enum Tab: Hashable { 10 enum Tab: Hashable {
11 case home 11 case home
12 case inbox
13 case repositories 12 case repositories
14 case builds
15 case tickets 13 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)
17 } 22 }
18 23
19 enum AuthPhase { 24 enum AuthPhase {
@@ -48,6 +53,7 @@ final class AppState {
48 53
49 /// Set by the deep link handler; consumed by RootView to drive navigation. 54 /// Set by the deep link handler; consumed by RootView to drive navigation.
50 var pendingDeepLink: DeepLink? 55 var pendingDeepLink: DeepLink?
56 var pendingTabNavigation: TabNavigationTarget?
51 57
52 // MARK: - Init 58 // MARK: - Init
53 59
@@ -126,9 +132,9 @@ final class AppState {
126 // MARK: - Deep link resolution 132 // MARK: - Deep link resolution
127 133
128 /// Resolve a repository by owner and name for deep linking. 134 /// 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 {
130 let result = try await client.execute( 136 let result = try await client.execute(
131 service: .git, 137 service: service,
132 query: Self.repoLookupQuery, 138 query: Self.repoLookupQuery,
133 variables: ["owner": owner, "name": name], 139 variables: ["owner": owner, "name": name],
134 responseType: RepoLookupResponse.self 140 responseType: RepoLookupResponse.self
@@ -147,6 +153,35 @@ final class AppState {
147 return result.user.tracker 153 return result.user.tracker
148 } 154 }
149 155
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
150 // MARK: - Private 185 // MARK: - Private
151 186
152 private static let meQuery = """ 187 private static let meQuery = """
@@ -221,6 +256,7 @@ final class AppState {
221 client.responseCache.clear() 256 client.responseCache.clear()
222 currentUser = nil 257 currentUser = nil
223 pendingDeepLink = nil 258 pendingDeepLink = nil
259 pendingTabNavigation = nil
224 selectedTab = .home 260 selectedTab = .home
225 } 261 }
226 262
Hutch/App/RootView.swift +94 −16
@@ -5,7 +5,7 @@ import SwiftUI
5struct RootView: View { 5struct RootView: View {
6 @Environment(AppState.self) private var appState 6 @Environment(AppState.self) private var appState
7 @State private var homePath = NavigationPath() 7 @State private var homePath = NavigationPath()
8 @State private var inboxPath = NavigationPath() 8 @State private var morePath = NavigationPath()
9 @State private var repoPath = NavigationPath() 9 @State private var repoPath = NavigationPath()
10 @State private var buildsPath = NavigationPath() 10 @State private var buildsPath = NavigationPath()
11 @State private var ticketsPath = NavigationPath() 11 @State private var ticketsPath = NavigationPath()
@@ -34,6 +34,9 @@ struct RootView: View {
34 .onChange(of: appState.authPhase) { _, newPhase in 34 .onChange(of: appState.authPhase) { _, newPhase in
35 handleAuthPhaseChange(newPhase) 35 handleAuthPhaseChange(newPhase)
36 } 36 }
37 .onChange(of: appState.pendingTabNavigation) { _, newValue in
38 consumePendingTabNavigationIfPossible(newValue)
39 }
37 } 40 }
38 41
39 // MARK: - Tab View 42 // MARK: - Tab View
@@ -50,14 +53,6 @@ struct RootView: View {
50 Label("Home", systemImage: "house") 53 Label("Home", systemImage: "house")
51 } 54 }
52 55
53 NavigationStack(path: $inboxPath) {
54 InboxView()
55 }
56 .tag(AppState.Tab.inbox)
57 .tabItem {
58 Label("Inbox", systemImage: "tray")
59 }
60
61 NavigationStack(path: $repoPath) { 56 NavigationStack(path: $repoPath) {
62 RepositoryListView() 57 RepositoryListView()
63 } 58 }
@@ -78,12 +73,6 @@ struct RootView: View {
78 Label("Tickets", systemImage: "ticket") 73 Label("Tickets", systemImage: "ticket")
79 } 74 }
80 75
81 SettingsView()
82 .tag(AppState.Tab.settings)
83 .tabItem {
84 Label("Settings", systemImage: "gear")
85 }
86
87 NavigationStack(path: $buildsPath) { 76 NavigationStack(path: $buildsPath) {
88 BuildListView() 77 BuildListView()
89 // Int destination used by deep links (hutch://builds/<id>). 78 // Int destination used by deep links (hutch://builds/<id>).
@@ -96,6 +85,14 @@ struct RootView: View {
96 .tabItem { 85 .tabItem {
97 Label("Builds", systemImage: "hammer") 86 Label("Builds", systemImage: "hammer")
98 } 87 }
88
89 NavigationStack(path: $morePath) {
90 MoreNavigationRoot()
91 }
92 .tag(AppState.Tab.more)
93 .tabItem {
94 Label("More", systemImage: "ellipsis.circle")
95 }
99 } 96 }
100 .overlay { 97 .overlay {
101 if isResolvingDeepLink { 98 if isResolvingDeepLink {
@@ -118,7 +115,7 @@ struct RootView: View {
118 break 115 break
119 case .unauthenticated: 116 case .unauthenticated:
120 homePath = NavigationPath() 117 homePath = NavigationPath()
121 inboxPath = NavigationPath() 118 morePath = NavigationPath()
122 repoPath = NavigationPath() 119 repoPath = NavigationPath()
123 buildsPath = NavigationPath() 120 buildsPath = NavigationPath()
124 ticketsPath = NavigationPath() 121 ticketsPath = NavigationPath()
@@ -135,6 +132,12 @@ struct RootView: View {
135 appState.pendingDeepLink = nil 132 appState.pendingDeepLink = nil
136 } 133 }
137 134
135 private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
136 guard appState.isAuthenticated, let target else { return }
137 handleTabNavigation(target)
138 appState.pendingTabNavigation = nil
139 }
140
138 private func handleDeepLink(_ link: DeepLink) { 141 private func handleDeepLink(_ link: DeepLink) {
139 guard appState.isAuthenticated else { return } 142 guard appState.isAuthenticated else { return }
140 143
@@ -157,6 +160,36 @@ struct RootView: View {
157 } 160 }
158 } 161 }
159 162
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
160 private func resolveRepositoryLink(owner: String, repo: String) { 193 private func resolveRepositoryLink(owner: String, repo: String) {
161 isResolvingDeepLink = true 194 isResolvingDeepLink = true
162 Task { 195 Task {
@@ -198,6 +231,51 @@ struct RootView: View {
198 } 231 }
199} 232}
200 233
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
201// MARK: - Ticket Deep Link Navigation Target 279// MARK: - Ticket Deep Link Navigation Target
202 280
203/// Hashable wrapper to push a ticket detail view from a deep link. 281/// Hashable wrapper to push a ticket detail view from a deep link.
Hutch/Extensions/SRHTShareUI.swift +1
@@ -9,6 +9,7 @@ enum SRHTShareTarget: String {
9 case tracker = "tracker" 9 case tracker = "tracker"
10 case ticket = "ticket" 10 case ticket = "ticket"
11 case profile = "profile" 11 case profile = "profile"
12 case paste = "paste"
12 13
13 var fallbackMessage: String { 14 var fallbackMessage: String {
14 "This \(rawValue) does not have a valid web URL to share." 15 "This \(rawValue) does not have a valid web URL to share."
Hutch/Extensions/SRHTWebURL.swift +8
@@ -76,6 +76,14 @@ enum SRHTWebURL {
76 ) 76 )
77 } 77 }
78 78
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
79 private static func userScopedURL( 87 private static func userScopedURL(
80 host: String, 88 host: String,
81 ownerCanonicalName: String, 89 ownerCanonicalName: String,
Hutch/Models/Inbox.swift +1 −1
@@ -150,7 +150,7 @@ struct MailComposeDraft: Sendable {
150 150
151extension MailComposeDraft: Identifiable {} 151extension MailComposeDraft: Identifiable {}
152 152
153struct InboxMailingListReference: Decodable, Sendable, Hashable { 153struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable {
154 let id: Int 154 let id: Int
155 let rid: String 155 let rid: String
156 let name: String 156 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 {
34 try container.encode(v) 34 try container.encode(v)
35 case let v as Bool: 35 case let v as Bool:
36 try container.encode(v) 36 try container.encode(v)
37 case let v as [String?]:
38 try container.encode(v)
37 case let v as [any Sendable]: 39 case let v as [any Sendable]:
38 try container.encode(v.map { AnyCodable($0) }) 40 try container.encode(v.map { AnyCodable($0) })
39 case let v as [String: any Sendable]: 41 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
3 3
4private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient") 4private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient")
5 5
6struct MultipartUploadFile: Sendable {
7 let variablePath: String
8 let fileData: Data
9 let fileName: String
10 let mimeType: String
11}
12
6/// Placeholder type for decoding GraphQL error responses when the data shape is unknown. 13/// Placeholder type for decoding GraphQL error responses when the data shape is unknown.
7private struct EmptyData: Decodable {} 14private struct EmptyData: Decodable {}
8 15
@@ -298,6 +305,100 @@ final class SRHTClient: Sendable {
298 return result 305 return result
299 } 306 }
300 307
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
301 // MARK: - Cached Execute 402 // MARK: - Cached Execute
302 403
303 /// Execute a query and cache the raw response data. Returns cached data 404 /// Execute a query and cache the raw response data. Returns cached data
Hutch/Networking/SRHTService.swift +9 −2
@@ -3,6 +3,7 @@ import Foundation
3/// Each Sourcehut service exposes its own GraphQL endpoint. 3/// Each Sourcehut service exposes its own GraphQL endpoint.
4enum SRHTService: String, Codable, Sendable, CaseIterable { 4enum SRHTService: String, Codable, Sendable, CaseIterable {
5 case meta 5 case meta
6 case hub
6 case git 7 case git
7 case hg 8 case hg
8 case builds 9 case builds
@@ -14,13 +15,19 @@ enum SRHTService: String, Codable, Sendable, CaseIterable {
14 15
15 /// The GraphQL endpoint URL for this service. 16 /// The GraphQL endpoint URL for this service.
16 var url: URL { 17 var url: URL {
17 // Force-unwrap is safe here — these are compile-time constant strings. 18 switch self {
18 URL(string: "https://\(rawValue).sr.ht/query")! 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 }
19 } 25 }
20 26
21 var displayName: String { 27 var displayName: String {
22 switch self { 28 switch self {
23 case .meta: "Meta" 29 case .meta: "Meta"
30 case .hub: "Hub"
24 case .git: "Git" 31 case .git: "Git"
25 case .hg: "Mercurial" 32 case .hg: "Mercurial"
26 case .builds: "Builds" 33 case .builds: "Builds"
Hutch/Views/Home/HomeView.swift +105 −4
@@ -4,6 +4,7 @@ struct HomeView: View {
4 @Environment(AppState.self) private var appState 4 @Environment(AppState.self) private var appState
5 @State private var viewModel: HomeViewModel? 5 @State private var viewModel: HomeViewModel?
6 private let previewLimit = 4 6 private let previewLimit = 4
7 private let projectPreviewLimit = 3
7 8
8 var body: some View { 9 var body: some View {
9 Group { 10 Group {
@@ -14,6 +15,15 @@ struct HomeView: View {
14 } 15 }
15 } 16 }
16 .navigationTitle("Home") 17 .navigationTitle("Home")
18 .toolbar {
19 ToolbarItem(placement: .topBarTrailing) {
20 NavigationLink {
21 InboxView()
22 } label: {
23 HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
24 }
25 }
26 }
17 .task { 27 .task {
18 if viewModel == nil, let currentUser = appState.currentUser { 28 if viewModel == nil, let currentUser = appState.currentUser {
19 let vm = HomeViewModel(currentUser: currentUser, client: appState.client) 29 let vm = HomeViewModel(currentUser: currentUser, client: appState.client)
@@ -26,16 +36,17 @@ struct HomeView: View {
26 @ViewBuilder 36 @ViewBuilder
27 private func content(_ viewModel: HomeViewModel) -> some View { 37 private func content(_ viewModel: HomeViewModel) -> some View {
28 List { 38 List {
39 projectsSection(viewModel)
29 assignedTicketsSection(viewModel) 40 assignedTicketsSection(viewModel)
30 recentBuildsSection(viewModel) 41 recentBuildsSection(viewModel)
31 } 42 }
32 .listStyle(.insetGrouped) 43 .listStyle(.insetGrouped)
33 .overlay { 44 .overlay {
34 if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && 45 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
35 viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { 46 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
36 SRHTLoadingStateView(message: "Loading Home…") 47 SRHTLoadingStateView(message: "Loading Home…")
37 } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && 48 } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
38 viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && 49 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
39 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { 50 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
40 ContentUnavailableView( 51 ContentUnavailableView(
41 "All Clear", 52 "All Clear",
@@ -49,6 +60,25 @@ struct HomeView: View {
49 } 60 }
50 } 61 }
51 62
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
52 @ViewBuilder 82 @ViewBuilder
53 private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { 83 private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
54 Section { 84 Section {
@@ -123,6 +153,77 @@ struct HomeView: View {
123 153
124} 154}
125 155
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
126private struct HomeBuildRow: View { 227private struct HomeBuildRow: View {
127 let build: HomeBuildItem 228 let build: HomeBuildItem
128 229
Hutch/Views/Home/HomeViewModel.swift +184
@@ -33,6 +33,36 @@ private struct HomeTrackerTicketsPage: Decodable, Sendable {
33 let results: [HomeTicketPayload] 33 let results: [HomeTicketPayload]
34} 34}
35 35
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
36private struct HomeTicketPayload: Decodable, Sendable { 66private struct HomeTicketPayload: Decodable, Sendable {
37 let id: Int 67 let id: Int
38 let title: String 68 let title: String
@@ -106,9 +136,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
106@Observable 136@Observable
107@MainActor 137@MainActor
108final class HomeViewModel { 138final class HomeViewModel {
139 private(set) var projects: [Project] = []
109 private(set) var failedBuilds: [HomeBuildItem] = [] 140 private(set) var failedBuilds: [HomeBuildItem] = []
110 private(set) var assignedTickets: [HomeAssignedTicket] = [] 141 private(set) var assignedTickets: [HomeAssignedTicket] = []
111 private(set) var recentBuilds: [HomeBuildItem] = [] 142 private(set) var recentBuilds: [HomeBuildItem] = []
143 private(set) var hasUnreadInboxThreads = false
144 private(set) var isLoadingProjects = false
112 private(set) var isLoadingFailedBuilds = false 145 private(set) var isLoadingFailedBuilds = false
113 private(set) var isLoadingAssignedTickets = false 146 private(set) var isLoadingAssignedTickets = false
114 private(set) var isLoadingRecentBuilds = false 147 private(set) var isLoadingRecentBuilds = false
@@ -118,7 +151,10 @@ final class HomeViewModel {
118 151
119 private let currentUser: User 152 private let currentUser: User
120 private let client: SRHTClient 153 private let client: SRHTClient
154 private let projectService: ProjectService
121 private let ticketFetchConcurrencyLimit = 6 155 private let ticketFetchConcurrencyLimit = 6
156 private let inboxUnreadConcurrencyLimit = 4
157 private let inboxUnreadThreadPreviewLimit = 10
122 158
123 private static let jobsQuery = """ 159 private static let jobsQuery = """
124 query jobs { 160 query jobs {
@@ -177,12 +213,45 @@ final class HomeViewModel {
177 } 213 }
178 """ 214 """
179 215
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
180 init(currentUser: User, client: SRHTClient) { 247 init(currentUser: User, client: SRHTClient) {
181 self.currentUser = currentUser 248 self.currentUser = currentUser
182 self.client = client 249 self.client = client
250 self.projectService = ProjectService(client: client)
183 } 251 }
184 252
185 func loadDashboard() async { 253 func loadDashboard() async {
254 isLoadingProjects = true
186 isLoadingFailedBuilds = true 255 isLoadingFailedBuilds = true
187 isLoadingAssignedTickets = true 256 isLoadingAssignedTickets = true
188 isLoadingRecentBuilds = true 257 isLoadingRecentBuilds = true
@@ -190,8 +259,19 @@ final class HomeViewModel {
190 assignedTicketsError = nil 259 assignedTicketsError = nil
191 recentBuildsError = nil 260 recentBuildsError = nil
192 261
262 async let projectsTask = loadProjects()
193 async let jobsTask = loadRecentJobs() 263 async let jobsTask = loadRecentJobs()
194 async let assignedTicketsTask = loadAssignedTickets() 264 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
195 275
196 let recentJobsResult = await jobsTask 276 let recentJobsResult = await jobsTask
197 277
@@ -222,6 +302,16 @@ final class HomeViewModel {
222 self.assignedTicketsError = error.localizedDescription 302 self.assignedTicketsError = error.localizedDescription
223 } 303 }
224 isLoadingAssignedTickets = false 304 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 }
225 } 315 }
226 316
227 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { 317 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
@@ -237,6 +327,100 @@ final class HomeViewModel {
237 } 327 }
238 } 328 }
239 329
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
240 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { 424 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
241 do { 425 do {
242 let trackers = try await fetchAllTrackers() 426 let trackers = try await fetchAllTrackers()
Hutch/Views/Inbox/InboxView.swift +6 −9
@@ -51,9 +51,9 @@ struct InboxView: View {
51 ) 51 )
52 } else if viewModel.threads.isEmpty, viewModel.error == nil { 52 } else if viewModel.threads.isEmpty, viewModel.error == nil {
53 ContentUnavailableView( 53 ContentUnavailableView(
54 "No Threads", 54 "Inbox Zero",
55 systemImage: "tray", 55 systemImage: "tray",
56 description: Text("Patch threads will appear here.") 56 description: Text("Unread threads will appear here.")
57 ) 57 )
58 } 58 }
59 } 59 }
@@ -75,19 +75,16 @@ struct InboxView: View {
75 private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View { 75 private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
76 Button { 76 Button {
77 withAnimation(.easeInOut(duration: 0.2)) { 77 withAnimation(.easeInOut(duration: 0.2)) {
78 viewModel.toggleThreadReadState(thread) 78 viewModel.markThreadRead(thread)
79 } 79 }
80 } label: { 80 } label: {
81 Label( 81 Label("Mark as Read", systemImage: "envelope.open")
82 thread.isUnread ? "Mark as Read" : "Mark as Unread",
83 systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
84 )
85 } 82 }
86 .tint(thread.isUnread ? .blue : .gray) 83 .tint(.blue)
87 } 84 }
88} 85}
89 86
90private struct InboxThreadRow: View { 87struct InboxThreadRow: View {
91 let thread: InboxThreadSummary 88 let thread: InboxThreadSummary
92 89
93 var body: some View { 90 var body: some View {
Hutch/Views/Inbox/InboxViewModel.swift +8 −2
@@ -127,7 +127,9 @@ final class InboxViewModel {
127 let subscriptions = try await fetchSubscriptions() 127 let subscriptions = try await fetchSubscriptions()
128 let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list)) 128 let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
129 let fetchedThreads = try await fetchThreads(for: mailingLists) 129 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
131 if lhs.lastActivityAt == rhs.lastActivityAt { 133 if lhs.lastActivityAt == rhs.lastActivityAt {
132 return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending 134 return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
133 } 135 }
@@ -145,7 +147,7 @@ final class InboxViewModel {
145 inboxListLogger.debug( 147 inboxListLogger.debug(
146 "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)" 148 "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
147 ) 149 )
148 updateThread(thread, isUnread: false) 150 threads.removeAll { $0.id == thread.id }
149 } 151 }
150 152
151 func markThreadUnread(_ thread: InboxThreadSummary) { 153 func markThreadUnread(_ thread: InboxThreadSummary) {
@@ -329,6 +331,10 @@ final class InboxViewModel {
329 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) { 331 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
330 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } 332 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
331 let current = threads[index] 333 let current = threads[index]
334 if !isUnread {
335 threads.remove(at: index)
336 return
337 }
332 threads[index] = InboxThreadSummary( 338 threads[index] = InboxThreadSummary(
333 rootEmailID: current.rootEmailID, 339 rootEmailID: current.rootEmailID,
334 rootMessageID: current.rootMessageID, 340 rootMessageID: current.rootMessageID,
Hutch/Views/Inbox/ThreadDetailView.swift +51 −9
@@ -8,10 +8,29 @@ private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category:
8struct ThreadDetailView: View { 8struct ThreadDetailView: View {
9 let thread: InboxThreadSummary 9 let thread: InboxThreadSummary
10 let onViewed: () -> Void 10 let onViewed: () -> Void
11 var onMarkRead: (() -> Void)? = nil
12 var onMarkUnread: (() -> Void)? = nil
11 13
12 @Environment(AppState.self) private var appState 14 @Environment(AppState.self) private var appState
13 @State private var viewModel: ThreadViewModel? 15 @State private var viewModel: ThreadViewModel?
14 @State private var replySuccessMessage: String? 16 @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 }
15 34
16 var body: some View { 35 var body: some View {
17 Group { 36 Group {
@@ -23,13 +42,21 @@ struct ThreadDetailView: View {
23 } 42 }
24 .navigationTitle("Thread") 43 .navigationTitle("Thread")
25 .navigationBarTitleDisplayMode(.inline) 44 .navigationBarTitleDisplayMode(.inline)
26 .task { 45 .task(id: thread.id) {
27 if viewModel == nil { 46 guard loadedThreadID != thread.id else { return }
28 onViewed() 47 let vm = ThreadViewModel(summary: thread, client: appState.client)
29 let vm = ThreadViewModel(summary: thread, client: appState.client) 48 viewModel = vm
30 viewModel = vm 49 loadedThreadID = thread.id
31 await vm.loadThread() 50 hasMarkedCurrentThreadViewed = false
32 } 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()
33 } 60 }
34 .sheet(item: Binding( 61 .sheet(item: Binding(
35 get: { viewModel?.composeDraft }, 62 get: { viewModel?.composeDraft },
@@ -109,8 +136,23 @@ struct ThreadDetailView: View {
109 .listStyle(.plain) 136 .listStyle(.plain)
110 .toolbar { 137 .toolbar {
111 ToolbarItem(placement: .topBarTrailing) { 138 ToolbarItem(placement: .topBarTrailing) {
112 Button("Reply") { 139 HStack {
113 viewModel.prepareReply() 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 }
114 } 156 }
115 } 157 }
116 } 158 }
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 {
12 @State private var pendingDestructiveAction: SettingsDestructiveAction? 12 @State private var pendingDestructiveAction: SettingsDestructiveAction?
13 13
14 var body: some View { 14 var body: some View {
15 NavigationStack { 15 Group {
16 Group { 16 if let viewModel {
17 if let viewModel { 17 settingsContent(viewModel)
18 settingsContent(viewModel) 18 } else {
19 } else { 19 SRHTLoadingStateView(message: "Loading profile…")
20 SRHTLoadingStateView(message: "Loading profile…")
21 }
22 } 20 }
23 .navigationTitle("Settings") 21 }
24 .task { 22 .navigationTitle("Settings")
25 if viewModel == nil { 23 .task {
26 let vm = SettingsViewModel(client: appState.client) 24 if viewModel == nil {
27 viewModel = vm 25 let vm = SettingsViewModel(client: appState.client)
28 await vm.loadProfile() 26 viewModel = vm
29 } 27 await vm.loadProfile()
30 } 28 }
31 } 29 }
32 } 30 }
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}