Commit 54c0b1dcd4

54c0b1dcd4b251dd72d12f9481e2153a420bf563

parent: d91b524fe6

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 02:33 UTC
committer: <noreply@github.com>

Phase 1: close the write gaps (#3)

* refactor: collapse duplicated request paths in SRHTClient

Five request paths each repeated the token guard, header setup, status-code
handling, and a ~35-line #if DEBUG logging block. The file carried that block
five times over.

Extract makeAuthorizedRequest, send, and encodedGraphQLBody, and route execute,
executeAndCache, executeMultipartFiles, and performGraphQLRequest through them.
executeMultipart is now the single-file case of executeMultipartFiles, which it
already was byte for byte. 938 lines to 612, with one copy of the logging block.

fetchText keeps its own guard: it is a GET to an allowlisted URL and must not
run GraphQL error checks over what is usually a plain-text build log.

One behavior change falls out. executeAndCache wrote the raw response to the
cache before decoding, so a 200 carrying GraphQL errors was cached and then
thrown. Routing it through performGraphQLRequest surfaces those errors first,
so error payloads are no longer cached.

* feat: edit and delete tickets

updateTicket and deleteTicket both existed in todo.sr.ht's API but were never
called, so a ticket could be filed and its status changed but its subject and
body were frozen from the moment it was created, and it could never be removed.

Edit opens a sheet seeded with the current subject and body. The input carries
only fields that actually changed, so an edit cannot clobber a field the user
did not touch, and Save stays disabled until something differs.

Clearing the body sends an explicit null via updateValue rather than a nil
subscript assignment, which would drop the key and silently leave the old body
in place — the same trap fixed for repository descriptions in 2d5bac7.

Delete is destructive and irreversible, so it sits behind a confirmation
dialog naming the ticket and pops the detail view on success.

* feat: subscribe to and unsubscribe from tickets

ticketSubscribe and ticketUnsubscribe existed in the API but were never called,
so email notifications for a ticket could only be managed on the web.

Ticket.subscription is null when the user is not subscribed, so the detail
query now reads it and the menu reflects real server state rather than
guessing. The toggle updates optimistically and reverts on failure, so the
control never claims a subscription that did not take.

Decoded into the private payload rather than TicketDetail, which is Codable and
cached — adding a field there would have changed the cached shape and touched
every optimistic-update construction site.

* feat: subscribe to and unsubscribe from trackers

trackerSubscribe and trackerUnsubscribe existed in the API but were never
called. Tracker.subscription is null when not subscribed, so the state can be
read rather than guessed.

The read is a separate uncached query. The tickets query it sits beside is
paginated and cached, and a per-user subscription has no business riding along
in page payloads or being served stale from disk.

Unsubscribe passes tickets: false, so leaving a tracker does not silently drop
subscriptions to individual tickets the user opted into.

* feat: unsubscribe from mailing lists

mailingListUnsubscribe existed in the API but was never called, so the list of
subscriptions was readable and nothing more.

Scoped to unsubscribe. MailingList has no subscription field, unlike Ticket and
Tracker, so per-list state is only knowable from the subscriptions query — which
is exactly what builds this view. Subscribing would need a list the user is by
definition not subscribed to, and sr.ht exposes no discovery API to find one
(see SCOPE.md on hub.sr.ht), so there is nowhere honest to put that action yet.

The row is removed optimistically and restored if the mutation fails. The
confirmation says plainly that Hutch cannot resubscribe, since it cannot.

* feat: manage todo and lists email preferences

updatePreferences existed on both services but was never called, so these were
web-only settings.

The two services expose preferences/updatePreferences under identical names but
with different fields — notifySelf on todo, copySelf on lists — and there is no
shared preferences service, so both are read and written side by side. They load
concurrently and one service being unreachable does not hide the other's toggle.

These are server-side and apply beyond Hutch, unlike the @AppStorage toggles
above them in Settings, so the footer says so and each toggle reverts if its
mutation fails.

* refactor: drop the memory-only cache path

Two executeCached overloads existed with different return types and semantics:
one doing stale-while-revalidate against the persistent cache with TTLs, the
other only consulting the in-memory responseCache. The second was an easy thing
to reach for by mistake, since the compiler picked it purely on argument labels.

It turned out to be dead. All 38 call sites already used the TTL-aware
overload, and the memory-only one was the sole caller of executeAndCache, so
both are removed. Its doc comment promised refresh "via the onRefresh callback",
which the signature has not had for some time.

SRHTClient is now 569 lines, down from 938 before this branch.

responseCache stays as the in-memory layer behind cachedPayload and the three
view models that read it directly.

* chore: bump to 3.6.0 and record Phase 1

MARKETING_VERSION 3.5.0 -> 3.6.0, build 87 -> 88.

* fix: decode preferences responses on the main actor

The module sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so the response
types are implicitly main-actor isolated and their Decodable conformances are
too. Decoding straight from an `async let` used those conformances from a
nonisolated context, which warns today and is an error in the Swift 6 language
mode.

Move each fetch into its own method and `async let` over those instead, so
decoding stays on the main actor. This is what HomeViewModel.loadDashboard
already does, and the concurrency is unaffected — the network work still
overlaps, since execute suspends and frees the actor.

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +12 −12
@@ -597,7 +597,7 @@
597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
599 CODE_SIGN_STYLE = Automatic; 599 CODE_SIGN_STYLE = Automatic;
600 CURRENT_PROJECT_VERSION = 87; 600 CURRENT_PROJECT_VERSION = 88;
601 DEVELOPMENT_TEAM = ZCNAX3VL9D; 601 DEVELOPMENT_TEAM = ZCNAX3VL9D;
602 ENABLE_PREVIEWS = YES; 602 ENABLE_PREVIEWS = YES;
603 GENERATE_INFOPLIST_FILE = YES; 603 GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
614 "$(inherited)", 614 "$(inherited)",
615 "@executable_path/Frameworks", 615 "@executable_path/Frameworks",
616 ); 616 );
617 MARKETING_VERSION = 3.5.0; 617 MARKETING_VERSION = 3.6.0;
618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
619 PRODUCT_NAME = "$(TARGET_NAME)"; 619 PRODUCT_NAME = "$(TARGET_NAME)";
620 STRING_CATALOG_GENERATE_SYMBOLS = YES; 620 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
636 CODE_SIGN_STYLE = Automatic; 636 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 87; 637 CURRENT_PROJECT_VERSION = 88;
638 DEVELOPMENT_TEAM = ZCNAX3VL9D; 638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639 ENABLE_PREVIEWS = YES; 639 ENABLE_PREVIEWS = YES;
640 GENERATE_INFOPLIST_FILE = YES; 640 GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
651 "$(inherited)", 651 "$(inherited)",
652 "@executable_path/Frameworks", 652 "@executable_path/Frameworks",
653 ); 653 );
654 MARKETING_VERSION = 3.5.0; 654 MARKETING_VERSION = 3.6.0;
655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
656 PRODUCT_NAME = "$(TARGET_NAME)"; 656 PRODUCT_NAME = "$(TARGET_NAME)";
657 STRING_CATALOG_GENERATE_SYMBOLS = YES; 657 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
714 APPLICATION_EXTENSION_API_ONLY = YES; 714 APPLICATION_EXTENSION_API_ONLY = YES;
715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
716 CODE_SIGN_STYLE = Automatic; 716 CODE_SIGN_STYLE = Automatic;
717 CURRENT_PROJECT_VERSION = 87; 717 CURRENT_PROJECT_VERSION = 88;
718 DEVELOPMENT_TEAM = ZCNAX3VL9D; 718 DEVELOPMENT_TEAM = ZCNAX3VL9D;
719 GENERATE_INFOPLIST_FILE = NO; 719 GENERATE_INFOPLIST_FILE = NO;
720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
724 "@executable_path/Frameworks", 724 "@executable_path/Frameworks",
725 "@executable_path/../../Frameworks", 725 "@executable_path/../../Frameworks",
726 ); 726 );
727 MARKETING_VERSION = 3.5.0; 727 MARKETING_VERSION = 3.6.0;
728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
729 PRODUCT_NAME = "$(TARGET_NAME)"; 729 PRODUCT_NAME = "$(TARGET_NAME)";
730 SKIP_INSTALL = YES; 730 SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
743 APPLICATION_EXTENSION_API_ONLY = YES; 743 APPLICATION_EXTENSION_API_ONLY = YES;
744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
745 CODE_SIGN_STYLE = Automatic; 745 CODE_SIGN_STYLE = Automatic;
746 CURRENT_PROJECT_VERSION = 87; 746 CURRENT_PROJECT_VERSION = 88;
747 DEVELOPMENT_TEAM = ZCNAX3VL9D; 747 DEVELOPMENT_TEAM = ZCNAX3VL9D;
748 GENERATE_INFOPLIST_FILE = NO; 748 GENERATE_INFOPLIST_FILE = NO;
749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
753 "@executable_path/Frameworks", 753 "@executable_path/Frameworks",
754 "@executable_path/../../Frameworks", 754 "@executable_path/../../Frameworks",
755 ); 755 );
756 MARKETING_VERSION = 3.5.0; 756 MARKETING_VERSION = 3.6.0;
757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
758 PRODUCT_NAME = "$(TARGET_NAME)"; 758 PRODUCT_NAME = "$(TARGET_NAME)";
759 SKIP_INSTALL = YES; 759 SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
772 APPLICATION_EXTENSION_API_ONLY = YES; 772 APPLICATION_EXTENSION_API_ONLY = YES;
773 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 773 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
774 CODE_SIGN_STYLE = Automatic; 774 CODE_SIGN_STYLE = Automatic;
775 CURRENT_PROJECT_VERSION = 87; 775 CURRENT_PROJECT_VERSION = 88;
776 DEVELOPMENT_TEAM = ZCNAX3VL9D; 776 DEVELOPMENT_TEAM = ZCNAX3VL9D;
777 GENERATE_INFOPLIST_FILE = NO; 777 GENERATE_INFOPLIST_FILE = NO;
778 INFOPLIST_FILE = HutchSafariExtension/Info.plist; 778 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
782 "@executable_path/Frameworks", 782 "@executable_path/Frameworks",
783 "@executable_path/../../Frameworks", 783 "@executable_path/../../Frameworks",
784 ); 784 );
785 MARKETING_VERSION = 3.5.0; 785 MARKETING_VERSION = 3.6.0;
786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; 786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
787 PRODUCT_NAME = "$(TARGET_NAME)"; 787 PRODUCT_NAME = "$(TARGET_NAME)";
788 SKIP_INSTALL = YES; 788 SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
801 APPLICATION_EXTENSION_API_ONLY = YES; 801 APPLICATION_EXTENSION_API_ONLY = YES;
802 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 802 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
803 CODE_SIGN_STYLE = Automatic; 803 CODE_SIGN_STYLE = Automatic;
804 CURRENT_PROJECT_VERSION = 87; 804 CURRENT_PROJECT_VERSION = 88;
805 DEVELOPMENT_TEAM = ZCNAX3VL9D; 805 DEVELOPMENT_TEAM = ZCNAX3VL9D;
806 GENERATE_INFOPLIST_FILE = NO; 806 GENERATE_INFOPLIST_FILE = NO;
807 INFOPLIST_FILE = HutchSafariExtension/Info.plist; 807 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
811 "@executable_path/Frameworks", 811 "@executable_path/Frameworks",
812 "@executable_path/../../Frameworks", 812 "@executable_path/../../Frameworks",
813 ); 813 );
814 MARKETING_VERSION = 3.5.0; 814 MARKETING_VERSION = 3.6.0;
815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; 815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
816 PRODUCT_NAME = "$(TARGET_NAME)"; 816 PRODUCT_NAME = "$(TARGET_NAME)";
817 SKIP_INSTALL = YES; 817 SKIP_INSTALL = YES;
Hutch/Networking/SRHTClient.swift +43 −412
@@ -69,101 +69,8 @@ final class SRHTClient: Sendable {
69 variables: [String: any Sendable]? = nil, 69 variables: [String: any Sendable]? = nil,
70 responseType _: T.Type 70 responseType _: T.Type
71 ) async throws -> T { 71 ) async throws -> T {
72 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { 72 let data = try await performGraphQLRequest(service: service, query: query, variables: variables)
73 throw SRHTError.unauthorized 73 return try decodeGraphQLData(data, service: service, query: query, variables: variables)
74 }
75
76 // Build request
77 var request = URLRequest(url: service.url)
78 request.httpMethod = "POST"
79 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
80 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
81 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
82
83 let body = GraphQLRequestBody(
84 query: query,
85 variables: variables?.mapValues { AnyCodable($0) }
86 )
87 request.httpBody = try encoder.encode(body)
88
89 // Execute
90 let (data, response): (Data, URLResponse)
91 do {
92 (data, response) = try await session.data(for: request)
93 } catch {
94 throw SRHTError.networkError(error)
95 }
96
97 // Check HTTP status
98 if let http = response as? HTTPURLResponse {
99 if http.statusCode == 401 {
100 throw SRHTError.unauthorized
101 }
102 if !(200...299).contains(http.statusCode) {
103 try throwGraphQLErrorsIfPresent(in: data)
104 throw SRHTError.httpError(http.statusCode)
105 }
106 }
107
108 try throwGraphQLErrorsIfPresent(in: data)
109
110 // Decode GraphQL response envelope
111 let graphQLResponse: GraphQLResponse<T>
112 do {
113 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
114 } catch {
115 #if DEBUG
116 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
117 let variablesDescription = String(describing: variables)
118 if let decodingError = error as? DecodingError {
119 logger.error(
120 """
121 Decoding failed for \(String(describing: T.self), privacy: .public)
122 service: \(service.rawValue, privacy: .public)
123 query:
124 \(query, privacy: .public)
125 variables:
126 \(variablesDescription, privacy: .public)
127 decodingError:
128 \(String(describing: decodingError), privacy: .public)
129 response:
130 \(responseBody, privacy: .public)
131 """
132 )
133 } else {
134 logger.error(
135 """
136 Decoding failed for \(String(describing: T.self), privacy: .public)
137 service: \(service.rawValue, privacy: .public)
138 query:
139 \(query, privacy: .public)
140 variables:
141 \(variablesDescription, privacy: .public)
142 error:
143 \(String(describing: error), privacy: .public)
144 response:
145 \(responseBody, privacy: .public)
146 """
147 )
148 }
149 #else
150 logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
151 #endif
152 throw SRHTError.decodingError(error)
153 }
154
155 // Surface GraphQL-level errors
156 if let errors = graphQLResponse.errors, !errors.isEmpty {
157 throw SRHTError.graphQLErrors(errors)
158 }
159
160 guard let result = graphQLResponse.data else {
161 throw SRHTError.decodingError(
162 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
163 )
164 }
165
166 return result
167 } 74 }
168 75
169 func executeCached<T: Decodable>( 76 func executeCached<T: Decodable>(
@@ -308,132 +215,13 @@ final class SRHTClient: Sendable {
308 file: MultipartUploadFile, 215 file: MultipartUploadFile,
309 responseType _: T.Type 216 responseType _: T.Type
310 ) async throws -> T { 217 ) async throws -> T {
311 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { 218 try await executeMultipartFiles(
312 throw SRHTError.unauthorized 219 service: service,
313 }
314
315 let boundary = "Boundary-\(UUID().uuidString)"
316
317 var request = URLRequest(url: service.url)
318 request.httpMethod = "POST"
319 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
320 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
321 request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
322
323 // Build the operations JSON (file variable mapped to null)
324 let operationsBody = GraphQLRequestBody(
325 query: query, 220 query: query,
326 variables: variables.mapValues { AnyCodable($0) } 221 variables: variables,
222 files: [file],
223 responseType: T.self
327 ) 224 )
328 let operationsData = try encoder.encode(operationsBody)
329
330 // Build the map JSON: { "0": ["variables.<variablePath>"] }
331 let mapDict = ["0": ["variables.\(file.variablePath)"]]
332 let mapData = try encoder.encode(mapDict)
333
334 // Assemble multipart body
335 var body = Data()
336
337 // Part: operations
338 body.append("--\(boundary)\r\n")
339 body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
340 body.append("Content-Type: application/json\r\n\r\n")
341 body.append(operationsData)
342 body.append("\r\n")
343
344 // Part: map
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 // Part: file
352 body.append("--\(boundary)\r\n")
353 body.append("Content-Disposition: form-data; name=\"0\"; 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 // Closing boundary
359 body.append("--\(boundary)--\r\n")
360
361 request.httpBody = body
362
363 let (data, response): (Data, URLResponse)
364 do {
365 (data, response) = try await session.data(for: request)
366 } catch {
367 throw SRHTError.networkError(error)
368 }
369
370 if let http = response as? HTTPURLResponse {
371 if http.statusCode == 401 {
372 throw SRHTError.unauthorized
373 }
374 if !(200...299).contains(http.statusCode) {
375 try throwGraphQLErrorsIfPresent(in: data)
376 throw SRHTError.httpError(http.statusCode)
377 }
378 }
379
380 try throwGraphQLErrorsIfPresent(in: data)
381
382 let graphQLResponse: GraphQLResponse<T>
383 do {
384 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
385 } catch {
386 #if DEBUG
387 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
388 let variablesDescription = String(describing: variables)
389 if let decodingError = error as? DecodingError {
390 logger.error(
391 """
392 Decoding failed for \(String(describing: T.self), privacy: .public)
393 service: \(service.rawValue, privacy: .public)
394 query:
395 \(query, privacy: .public)
396 variables:
397 \(variablesDescription, privacy: .public)
398 decodingError:
399 \(String(describing: decodingError), privacy: .public)
400 response:
401 \(responseBody, privacy: .public)
402 """
403 )
404 } else {
405 logger.error(
406 """
407 Decoding failed for \(String(describing: T.self), privacy: .public)
408 service: \(service.rawValue, privacy: .public)
409 query:
410 \(query, privacy: .public)
411 variables:
412 \(variablesDescription, privacy: .public)
413 error:
414 \(String(describing: error), privacy: .public)
415 response:
416 \(responseBody, privacy: .public)
417 """
418 )
419 }
420 #else
421 logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
422 #endif
423 throw SRHTError.decodingError(error)
424 }
425
426 if let errors = graphQLResponse.errors, !errors.isEmpty {
427 throw SRHTError.graphQLErrors(errors)
428 }
429
430 guard let result = graphQLResponse.data else {
431 throw SRHTError.decodingError(
432 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
433 )
434 }
435
436 return result
437 } 225 }
438 226
439 func executeMultipartFiles<T: Decodable>( 227 func executeMultipartFiles<T: Decodable>(
@@ -443,23 +231,13 @@ final class SRHTClient: Sendable {
443 files: [MultipartUploadFile], 231 files: [MultipartUploadFile],
444 responseType _: T.Type 232 responseType _: T.Type
445 ) async throws -> T { 233 ) async throws -> T {
446 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
447 throw SRHTError.unauthorized
448 }
449
450 let boundary = "Boundary-\(UUID().uuidString)" 234 let boundary = "Boundary-\(UUID().uuidString)"
451 235 var request = try makeAuthorizedRequest(
452 var request = URLRequest(url: service.url) 236 service: service,
453 request.httpMethod = "POST" 237 contentType: "multipart/form-data; boundary=\(boundary)"
454 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
455 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
456 request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
457
458 let operationsBody = GraphQLRequestBody(
459 query: query,
460 variables: variables.mapValues { AnyCodable($0) }
461 ) 238 )
462 let operationsData = try encoder.encode(operationsBody) 239
240 let operationsData = try encodedGraphQLBody(query: query, variables: variables)
463 241
464 let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in 242 let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in
465 (String(index), ["variables.\(file.variablePath)"]) 243 (String(index), ["variables.\(file.variablePath)"])
@@ -491,174 +269,10 @@ final class SRHTClient: Sendable {
491 body.append("--\(boundary)--\r\n") 269 body.append("--\(boundary)--\r\n")
492 request.httpBody = body 270 request.httpBody = body
493 271
494 let (data, response): (Data, URLResponse) 272 let data = try await send(request)
495 do { 273 return try decodeGraphQLData(data, service: service, query: query, variables: variables)
496 (data, response) = try await session.data(for: request)
497 } catch {
498 throw SRHTError.networkError(error)
499 }
500
501 if let http = response as? HTTPURLResponse {
502 if http.statusCode == 401 {
503 throw SRHTError.unauthorized
504 }
505 if !(200...299).contains(http.statusCode) {
506 try throwGraphQLErrorsIfPresent(in: data)
507 throw SRHTError.httpError(http.statusCode)
508 }
509 }
510
511 try throwGraphQLErrorsIfPresent(in: data)
512
513 let graphQLResponse: GraphQLResponse<T>
514 do {
515 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
516 } catch {
517 throw SRHTError.decodingError(error)
518 }
519
520 if let errors = graphQLResponse.errors, !errors.isEmpty {
521 throw SRHTError.graphQLErrors(errors)
522 }
523
524 guard let result = graphQLResponse.data else {
525 throw SRHTError.decodingError(
526 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
527 )
528 }
529
530 return result
531 } 274 }
532 275
533 // MARK: - Cached Execute
534
535 /// Execute a query and cache the raw response data. Returns cached data
536 /// immediately on cache hit, then refreshes in the background via the
537 /// `onRefresh` callback.
538 func executeCached<T: Decodable>(
539 service: SRHTService,
540 query: String,
541 variables: [String: any Sendable]? = nil,
542 responseType _: T.Type,
543 cacheKey: String
544 ) async throws -> T {
545 // Try cache first
546 if let cachedData = responseCache.get(forKey: cacheKey),
547 let cached = try? decoder.decode(GraphQLResponse<T>.self, from: cachedData),
548 let data = cached.data {
549 return data
550 }
551
552 // No cache hit — fetch normally
553 return try await executeAndCache(
554 service: service,
555 query: query,
556 variables: variables,
557 responseType: T.self,
558 cacheKey: cacheKey
559 )
560 }
561
562 /// Execute a query, cache the raw data, and return the decoded result.
563 func executeAndCache<T: Decodable>(
564 service: SRHTService,
565 query: String,
566 variables: [String: any Sendable]? = nil,
567 responseType _: T.Type,
568 cacheKey: String
569 ) async throws -> T {
570 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
571 throw SRHTError.unauthorized
572 }
573
574 var request = URLRequest(url: service.url)
575 request.httpMethod = "POST"
576 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
577 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
578 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
579
580 let body = GraphQLRequestBody(
581 query: query,
582 variables: variables?.mapValues { AnyCodable($0) }
583 )
584 request.httpBody = try encoder.encode(body)
585
586 let (data, response): (Data, URLResponse)
587 do {
588 (data, response) = try await session.data(for: request)
589 } catch {
590 throw SRHTError.networkError(error)
591 }
592
593 if let http = response as? HTTPURLResponse {
594 if http.statusCode == 401 {
595 throw SRHTError.unauthorized
596 }
597 if !(200...299).contains(http.statusCode) {
598 try throwGraphQLErrorsIfPresent(in: data)
599 throw SRHTError.httpError(http.statusCode)
600 }
601 }
602
603 // Cache the raw response data before decoding
604 responseCache.set(data, forKey: cacheKey)
605
606 let graphQLResponse: GraphQLResponse<T>
607 do {
608 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
609 } catch {
610 #if DEBUG
611 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
612 let variablesDescription = String(describing: variables)
613 if let decodingError = error as? DecodingError {
614 logger.error(
615 """
616 Decoding failed for \(String(describing: T.self), privacy: .public)
617 service: \(service.rawValue, privacy: .public)
618 query:
619 \(query, privacy: .public)
620 variables:
621 \(variablesDescription, privacy: .public)
622 decodingError:
623 \(String(describing: decodingError), privacy: .public)
624 response:
625 \(responseBody, privacy: .public)
626 """
627 )
628 } else {
629 logger.error(
630 """
631 Decoding failed for \(String(describing: T.self), privacy: .public)
632 service: \(service.rawValue, privacy: .public)
633 query:
634 \(query, privacy: .public)
635 variables:
636 \(variablesDescription, privacy: .public)
637 error:
638 \(String(describing: error), privacy: .public)
639 response:
640 \(responseBody, privacy: .public)
641 """
642 )
643 }
644 #else
645 logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
646 #endif
647 throw SRHTError.decodingError(error)
648 }
649
650 if let errors = graphQLResponse.errors, !errors.isEmpty {
651 throw SRHTError.graphQLErrors(errors)
652 }
653
654 guard let result = graphQLResponse.data else {
655 throw SRHTError.decodingError(
656 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
657 )
658 }
659
660 return result
661 }
662 276
663 // MARK: - Plain-text fetch 277 // MARK: - Plain-text fetch
664 278
@@ -748,11 +362,9 @@ final class SRHTClient: Sendable {
748// MARK: - Data Helper 362// MARK: - Data Helper
749 363
750private extension SRHTClient { 364private extension SRHTClient {
751 func performGraphQLRequest( 365 /// Builds an authorized POST for `service`. Throws ``SRHTError/unauthorized``
752 service: SRHTService, 366 /// when no token is set, so callers never have to guard separately.
753 query: String, 367 func makeAuthorizedRequest(service: SRHTService, contentType: String) throws -> URLRequest {
754 variables: [String: any Sendable]?
755 ) async throws -> Data {
756 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { 368 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
757 throw SRHTError.unauthorized 369 throw SRHTError.unauthorized
758 } 370 }
@@ -761,14 +373,14 @@ private extension SRHTClient {
761 request.httpMethod = "POST" 373 request.httpMethod = "POST"
762 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") 374 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
763 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") 375 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
764 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 376 request.setValue(contentType, forHTTPHeaderField: "Content-Type")
765 377 return request
766 let body = GraphQLRequestBody( 378 }
767 query: query,
768 variables: variables?.mapValues { AnyCodable($0) }
769 )
770 request.httpBody = try encoder.encode(body)
771 379
380 /// Sends a prepared request and returns the raw body, mapping transport and
381 /// HTTP failures onto ``SRHTError``. sr.ht reports GraphQL errors under a 200
382 /// as often as under a 4xx, so both paths check the envelope.
383 func send(_ request: URLRequest) async throws -> Data {
772 let (data, response): (Data, URLResponse) 384 let (data, response): (Data, URLResponse)
773 do { 385 do {
774 (data, response) = try await session.data(for: request) 386 (data, response) = try await session.data(for: request)
@@ -790,6 +402,25 @@ private extension SRHTClient {
790 return data 402 return data
791 } 403 }
792 404
405 func encodedGraphQLBody(query: String, variables: [String: any Sendable]?) throws -> Data {
406 try encoder.encode(
407 GraphQLRequestBody(
408 query: query,
409 variables: variables?.mapValues { AnyCodable($0) }
410 )
411 )
412 }
413
414 func performGraphQLRequest(
415 service: SRHTService,
416 query: String,
417 variables: [String: any Sendable]?
418 ) async throws -> Data {
419 var request = try makeAuthorizedRequest(service: service, contentType: "application/json")
420 request.httpBody = try encodedGraphQLBody(query: query, variables: variables)
421 return try await send(request)
422 }
423
793 func decodeGraphQLData<T: Decodable>( 424 func decodeGraphQLData<T: Decodable>(
794 _ data: Data, 425 _ data: Data,
795 service: SRHTService, 426 service: SRHTService,
Hutch/Views/Lists/MailingListListView.swift +67
@@ -5,6 +5,7 @@ import SwiftUI
5final class MailingListListViewModel { 5final class MailingListListViewModel {
6 private(set) var mailingLists: [InboxMailingListReference] = [] 6 private(set) var mailingLists: [InboxMailingListReference] = []
7 private(set) var isLoading = false 7 private(set) var isLoading = false
8 private(set) var isPerformingAction = false
8 var error: String? 9 var error: String?
9 var searchText = "" 10 var searchText = ""
10 11
@@ -28,10 +29,51 @@ final class MailingListListViewModel {
28 } 29 }
29 """ 30 """
30 31
32 private static let unsubscribeMutation = """
33 mutation mailingListUnsubscribe($listID: Int!) {
34 subscription: mailingListUnsubscribe(listID: $listID) { id }
35 }
36 """
37
31 init(client: SRHTClient) { 38 init(client: SRHTClient) {
32 self.client = client 39 self.client = client
33 } 40 }
34 41
42 /// Unsubscribes from a list and drops it from the list on success. This view
43 /// is built from the subscriptions query, so a successful unsubscribe means
44 /// the row no longer belongs here.
45 func unsubscribe(from mailingList: InboxMailingListReference) async {
46 guard !isPerformingAction else { return }
47 isPerformingAction = true
48 error = nil
49 defer { isPerformingAction = false }
50
51 let previousLists = mailingLists
52 mailingLists.removeAll { $0.rid == mailingList.rid }
53
54 do {
55 struct Response: Decodable, Sendable {
56 // mailingListUnsubscribe is nullable: sr.ht returns null when there
57 // was no subscription to remove, which is still a success.
58 let subscription: SubscriptionPayload?
59 }
60
61 struct SubscriptionPayload: Decodable, Sendable {
62 let id: Int
63 }
64
65 _ = try await client.execute(
66 service: .lists,
67 query: Self.unsubscribeMutation,
68 variables: ["listID": mailingList.id],
69 responseType: Response.self
70 )
71 } catch {
72 mailingLists = previousLists
73 self.error = "Couldn't unsubscribe from \(mailingList.name). \(error.userFacingMessage)"
74 }
75 }
76
35 var filteredMailingLists: [InboxMailingListReference] { 77 var filteredMailingLists: [InboxMailingListReference] {
36 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 78 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
37 guard !q.isEmpty else { return mailingLists } 79 guard !q.isEmpty else { return mailingLists }
@@ -106,6 +148,7 @@ final class MailingListListViewModel {
106struct MailingListListView: View { 148struct MailingListListView: View {
107 @Environment(AppState.self) private var appState 149 @Environment(AppState.self) private var appState
108 @State private var viewModel: MailingListListViewModel? 150 @State private var viewModel: MailingListListViewModel?
151 @State private var pendingUnsubscribe: InboxMailingListReference?
109 152
110 var body: some View { 153 var body: some View {
111 Group { 154 Group {
@@ -141,6 +184,14 @@ struct MailingListListView: View {
141 } 184 }
142 .padding(.vertical, 2) 185 .padding(.vertical, 2)
143 } 186 }
187 .swipeActions(edge: .trailing) {
188 Button {
189 pendingUnsubscribe = mailingList
190 } label: {
191 SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
192 }
193 .tint(.orange)
194 }
144 } 195 }
145 .themedRow() 196 .themedRow()
146 } 197 }
@@ -151,6 +202,22 @@ struct MailingListListView: View {
151 placement: .navigationBarDrawer(displayMode: .always), 202 placement: .navigationBarDrawer(displayMode: .always),
152 prompt: "Search lists" 203 prompt: "Search lists"
153 ) 204 )
205 .confirmationDialog(
206 pendingUnsubscribe.map { "Unsubscribe from \($0.name)?" } ?? "",
207 isPresented: .init(
208 get: { pendingUnsubscribe != nil },
209 set: { if !$0 { pendingUnsubscribe = nil } }
210 ),
211 titleVisibility: .visible,
212 presenting: pendingUnsubscribe
213 ) { mailingList in
214 Button("Unsubscribe", role: .destructive) {
215 Task { await viewModel.unsubscribe(from: mailingList) }
216 }
217 Button("Cancel", role: .cancel) { pendingUnsubscribe = nil }
218 } message: { _ in
219 Text("You will stop receiving email from this list. Hutch cannot resubscribe you — you would need to do that from the list's page on the web.")
220 }
154 .overlay { 221 .overlay {
155 if viewModel.isLoading, viewModel.mailingLists.isEmpty { 222 if viewModel.isLoading, viewModel.mailingLists.isEmpty {
156 SRHTLoadingStateView(message: "Loading mailing lists…") 223 SRHTLoadingStateView(message: "Loading mailing lists…")
Hutch/Views/Settings/NotificationPreferencesViewModel.swift added +173
@@ -0,0 +1,173 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct TodoPreferencesResponse: Decodable, Sendable {
6 let preferences: TodoPreferences
7}
8
9private struct TodoPreferences: Decodable, Sendable {
10 let notifySelf: Bool
11}
12
13private struct ListsPreferencesResponse: Decodable, Sendable {
14 let preferences: ListsPreferences
15}
16
17private struct ListsPreferences: Decodable, Sendable {
18 let copySelf: Bool
19}
20
21// MARK: - View Model
22
23/// Email preferences for todo.sr.ht and lists.sr.ht.
24///
25/// The two services each expose `preferences`/`updatePreferences` under the same
26/// names but with different fields — `notifySelf` on todo, `copySelf` on lists —
27/// and there is no shared preferences service, so both are handled side by side.
28@Observable
29@MainActor
30final class NotificationPreferencesViewModel {
31
32 private(set) var notifySelf = false
33 private(set) var copySelf = false
34 private(set) var isLoading = false
35 private(set) var isSavingNotifySelf = false
36 private(set) var isSavingCopySelf = false
37 private(set) var hasLoaded = false
38 var error: String?
39
40 private let client: SRHTClient
41
42 init(client: SRHTClient) {
43 self.client = client
44 }
45
46 private static let todoPreferencesQuery = """
47 query todoPreferences {
48 preferences { notifySelf }
49 }
50 """
51
52 private static let listsPreferencesQuery = """
53 query listsPreferences {
54 preferences { copySelf }
55 }
56 """
57
58 private static let updateNotifySelfMutation = """
59 mutation updateTodoPreferences($notifySelf: Boolean!) {
60 preferences: updatePreferences(preferences: { notifySelf: $notifySelf }) {
61 notifySelf
62 }
63 }
64 """
65
66 private static let updateCopySelfMutation = """
67 mutation updateListsPreferences($copySelf: Boolean!) {
68 preferences: updatePreferences(preferences: { copySelf: $copySelf }) {
69 copySelf
70 }
71 }
72 """
73
74 func loadIfNeeded() async {
75 guard !hasLoaded, !isLoading else { return }
76 await load()
77 }
78
79 func load() async {
80 isLoading = true
81 error = nil
82 defer {
83 isLoading = false
84 hasLoaded = true
85 }
86
87 // The two services are independent; one being unreachable should not hide
88 // the other's setting.
89 async let todo = fetchNotifySelf()
90 async let lists = fetchCopySelf()
91
92 let (todoResult, listsResult) = await (todo, lists)
93
94 if let todoResult {
95 notifySelf = todoResult
96 }
97 if let listsResult {
98 copySelf = listsResult
99 }
100
101 if todoResult == nil && listsResult == nil {
102 error = "Couldn't load your email preferences."
103 }
104 }
105
106 /// The fetches stay in their own methods so the response types are only ever
107 /// decoded on the main actor. The module defaults to MainActor isolation, so
108 /// decoding straight from an `async let` would use a main-actor-isolated
109 /// Decodable conformance from a nonisolated context.
110 private func fetchNotifySelf() async -> Bool? {
111 let response = try? await client.execute(
112 service: .todo,
113 query: Self.todoPreferencesQuery,
114 responseType: TodoPreferencesResponse.self
115 )
116 return response?.preferences.notifySelf
117 }
118
119 private func fetchCopySelf() async -> Bool? {
120 let response = try? await client.execute(
121 service: .lists,
122 query: Self.listsPreferencesQuery,
123 responseType: ListsPreferencesResponse.self
124 )
125 return response?.preferences.copySelf
126 }
127
128 func setNotifySelf(_ newValue: Bool) async {
129 guard !isSavingNotifySelf else { return }
130 isSavingNotifySelf = true
131 error = nil
132 defer { isSavingNotifySelf = false }
133
134 let previous = notifySelf
135 notifySelf = newValue
136
137 do {
138 let response = try await client.execute(
139 service: .todo,
140 query: Self.updateNotifySelfMutation,
141 variables: ["notifySelf": newValue],
142 responseType: TodoPreferencesResponse.self
143 )
144 notifySelf = response.preferences.notifySelf
145 } catch {
146 notifySelf = previous
147 self.error = "Couldn't update ticket email preference. \(error.userFacingMessage)"
148 }
149 }
150
151 func setCopySelf(_ newValue: Bool) async {
152 guard !isSavingCopySelf else { return }
153 isSavingCopySelf = true
154 error = nil
155 defer { isSavingCopySelf = false }
156
157 let previous = copySelf
158 copySelf = newValue
159
160 do {
161 let response = try await client.execute(
162 service: .lists,
163 query: Self.updateCopySelfMutation,
164 variables: ["copySelf": newValue],
165 responseType: ListsPreferencesResponse.self
166 )
167 copySelf = response.preferences.copySelf
168 } catch {
169 copySelf = previous
170 self.error = "Couldn't update mailing list email preference. \(error.userFacingMessage)"
171 }
172 }
173}
Hutch/Views/Settings/SettingsView.swift +52
@@ -10,16 +10,23 @@ struct SettingsView: View {
10 private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays 10 private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
11 @State private var pendingDestructiveAction: SettingsDestructiveAction? 11 @State private var pendingDestructiveAction: SettingsDestructiveAction?
12 @State private var showAccountSwitcher = false 12 @State private var showAccountSwitcher = false
13 @State private var preferences: NotificationPreferencesViewModel?
13 14
14 var body: some View { 15 var body: some View {
15 Form { 16 Form {
16 appearanceSection() 17 appearanceSection()
17 behaviorSection() 18 behaviorSection()
19 emailSection()
18 safariExtensionSection() 20 safariExtensionSection()
19 authenticationSection() 21 authenticationSection()
20 } 22 }
21 .themedList() 23 .themedList()
22 .navigationTitle("Settings") 24 .navigationTitle("Settings")
25 .task {
26 let viewModel = preferences ?? NotificationPreferencesViewModel(client: appState.client)
27 preferences = viewModel
28 await viewModel.loadIfNeeded()
29 }
23 .sheet(isPresented: $showAccountSwitcher) { 30 .sheet(isPresented: $showAccountSwitcher) {
24 AccountSwitcherView() 31 AccountSwitcherView()
25 } 32 }
@@ -117,6 +124,51 @@ struct SettingsView: View {
117 } 124 }
118 } 125 }
119 126
127 @ViewBuilder
128 private func emailSection() -> some View {
129 Section {
130 if let preferences {
131 Toggle(
132 "Notify me about my own tickets",
133 isOn: Binding(
134 get: { preferences.notifySelf },
135 set: { newValue in
136 Task { await preferences.setNotifySelf(newValue) }
137 }
138 )
139 )
140 .disabled(preferences.isLoading || preferences.isSavingNotifySelf)
141 .themedRow()
142
143 Toggle(
144 "Copy me on my own list mail",
145 isOn: Binding(
146 get: { preferences.copySelf },
147 set: { newValue in
148 Task { await preferences.setCopySelf(newValue) }
149 }
150 )
151 )
152 .disabled(preferences.isLoading || preferences.isSavingCopySelf)
153 .themedRow()
154
155 if let error = preferences.error {
156 Text(error)
157 .font(.caption)
158 .foregroundStyle(.red)
159 .themedRow()
160 }
161 } else {
162 ProgressView()
163 .themedRow()
164 }
165 } header: {
166 Text("Email")
167 } footer: {
168 Text("These are stored on SourceHut and apply everywhere, not just in Hutch. The first controls whether todo.sr.ht emails you about your own ticket activity; the second whether lists.sr.ht copies you on mail you send to a list.")
169 }
170 }
171
120 @ViewBuilder 172 @ViewBuilder
121 private func authenticationSection() -> some View { 173 private func authenticationSection() -> some View {
122 Section { 174 Section {
Hutch/Views/Tickets/TicketDetailView.swift +133
@@ -11,12 +11,15 @@ struct TicketDetailView: View {
11 @Environment(AppState.self) private var appState 11 @Environment(AppState.self) private var appState
12 @Environment(\.colorScheme) private var colorScheme 12 @Environment(\.colorScheme) private var colorScheme
13 @Environment(\.openURL) private var openURL 13 @Environment(\.openURL) private var openURL
14 @Environment(\.dismiss) private var dismiss
14 @State private var viewModel: TicketDetailViewModel? 15 @State private var viewModel: TicketDetailViewModel?
15 16
16 // Sheet state 17 // Sheet state
17 @State private var showResolveSheet = false 18 @State private var showResolveSheet = false
18 @State private var showAssignSheet = false 19 @State private var showAssignSheet = false
19 @State private var showLabelsSheet = false 20 @State private var showLabelsSheet = false
21 @State private var showEditSheet = false
22 @State private var showDeleteConfirmation = false
20 @State private var isOpeningTracker = false 23 @State private var isOpeningTracker = false
21 24
22 // Comment composer mode 25 // Comment composer mode
@@ -100,9 +103,30 @@ struct TicketDetailView: View {
100 SwiftUI.Label("Copy Tracker RID", systemImage: "number") 103 SwiftUI.Label("Copy Tracker RID", systemImage: "number")
101 } 104 }
102 105
106 Divider()
107
108 Button {
109 Task { await viewModel.toggleSubscription() }
110 } label: {
111 if viewModel.isSubscribed {
112 SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
113 } else {
114 SwiftUI.Label("Subscribe", systemImage: "bell")
115 }
116 }
117 .disabled(viewModel.isPerformingAction)
118
103 if isOwnedByCurrentUser { 119 if isOwnedByCurrentUser {
104 Divider() 120 Divider()
105 121
122 if viewModel.ticket != nil {
123 Button {
124 showEditSheet = true
125 } label: {
126 SwiftUI.Label("Edit Ticket", systemImage: "square.and.pencil")
127 }
128 }
129
106 if let ticket = viewModel.ticket { 130 if let ticket = viewModel.ticket {
107 if ticket.status == .resolved { 131 if ticket.status == .resolved {
108 Button { 132 Button {
@@ -133,6 +157,14 @@ struct TicketDetailView: View {
133 } label: { 157 } label: {
134 SwiftUI.Label("Manage Labels", systemImage: "tag") 158 SwiftUI.Label("Manage Labels", systemImage: "tag")
135 } 159 }
160
161 Divider()
162
163 Button(role: .destructive) {
164 showDeleteConfirmation = true
165 } label: {
166 SwiftUI.Label("Delete Ticket", systemImage: "trash")
167 }
136 } 168 }
137 } label: { 169 } label: {
138 Image(systemName: "ellipsis.circle") 170 Image(systemName: "ellipsis.circle")
@@ -150,6 +182,32 @@ struct TicketDetailView: View {
150 LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet) 182 LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
151 .presentationDetents([.medium]) 183 .presentationDetents([.medium])
152 } 184 }
185 .sheet(isPresented: $showEditSheet) {
186 if let ticket = viewModel.ticket {
187 EditTicketSheet(
188 viewModel: viewModel,
189 isPresented: $showEditSheet,
190 initialSubject: ticket.title,
191 initialBody: ticket.description ?? ""
192 )
193 }
194 }
195 .confirmationDialog(
196 "Delete Ticket #\(ticketId)?",
197 isPresented: $showDeleteConfirmation,
198 titleVisibility: .visible
199 ) {
200 Button("Delete Ticket", role: .destructive) {
201 Task {
202 if await viewModel.deleteTicket() {
203 dismiss()
204 }
205 }
206 }
207 Button("Cancel", role: .cancel) {}
208 } message: {
209 Text("This permanently deletes the ticket and its comments. This cannot be undone.")
210 }
153 } 211 }
154 212
155 // MARK: - Detail Content 213 // MARK: - Detail Content
@@ -724,6 +782,81 @@ private struct EventRow: View {
724 782
725// MARK: - Resolve Sheet 783// MARK: - Resolve Sheet
726 784
785private struct EditTicketSheet: View {
786 let viewModel: TicketDetailViewModel
787 @Binding var isPresented: Bool
788 let initialSubject: String
789 let initialBody: String
790
791 @State private var subject: String
792 @State private var ticketBody: String
793
794 init(
795 viewModel: TicketDetailViewModel,
796 isPresented: Binding<Bool>,
797 initialSubject: String,
798 initialBody: String
799 ) {
800 self.viewModel = viewModel
801 _isPresented = isPresented
802 self.initialSubject = initialSubject
803 self.initialBody = initialBody
804 _subject = State(initialValue: initialSubject)
805 _ticketBody = State(initialValue: initialBody)
806 }
807
808 private var trimmedSubject: String {
809 subject.trimmingCharacters(in: .whitespacesAndNewlines)
810 }
811
812 private var hasChanges: Bool {
813 trimmedSubject != initialSubject
814 || ticketBody.trimmingCharacters(in: .whitespacesAndNewlines) != initialBody
815 }
816
817 var body: some View {
818 NavigationStack {
819 Form {
820 Section("Subject") {
821 TextField("Subject", text: $subject, axis: .vertical)
822 .themedRow()
823 }
824
825 Section("Description") {
826 TextField("Description", text: $ticketBody, axis: .vertical)
827 .lineLimit(5...15)
828 .themedRow()
829 }
830 }
831 .themedList()
832 .navigationTitle("Edit Ticket")
833 .navigationBarTitleDisplayMode(.inline)
834 .toolbar {
835 ToolbarItem(placement: .cancellationAction) {
836 Button("Cancel") { isPresented = false }
837 }
838 ToolbarItem(placement: .confirmationAction) {
839 Button("Save") {
840 Task {
841 if await viewModel.updateTicket(subject: subject, body: ticketBody) {
842 isPresented = false
843 }
844 }
845 }
846 .disabled(viewModel.isPerformingAction || trimmedSubject.isEmpty || !hasChanges)
847 }
848 }
849 .overlay {
850 if viewModel.isPerformingAction {
851 ProgressView()
852 }
853 }
854 }
855 }
856}
857
858// MARK: - Resolve Sheet
859
727private struct ResolveSheet: View { 860private struct ResolveSheet: View {
728 let viewModel: TicketDetailViewModel 861 let viewModel: TicketDetailViewModel
729 @Binding var isPresented: Bool 862 @Binding var isPresented: Bool
Hutch/Views/Tickets/TicketDetailViewModel.swift +173
@@ -19,12 +19,18 @@ private struct TicketDetailPayload: Decodable, Sendable {
19 let status: TicketStatus 19 let status: TicketStatus
20 let resolution: TicketResolution? 20 let resolution: TicketResolution?
21 let authenticity: Authenticity 21 let authenticity: Authenticity
22 /// Null when the authenticated user is not subscribed to this ticket.
23 let subscription: SubscriptionIdPayload?
22 let submitter: Entity 24 let submitter: Entity
23 let assignees: [Entity] 25 let assignees: [Entity]
24 let labels: [TicketLabel] 26 let labels: [TicketLabel]
25 let events: EventsPage 27 let events: EventsPage
26} 28}
27 29
30struct SubscriptionIdPayload: Decodable, Sendable {
31 let id: Int
32}
33
28private struct EventsPage: Decodable, Sendable { 34private struct EventsPage: Decodable, Sendable {
29 let results: [TicketEvent] 35 let results: [TicketEvent]
30 let cursor: String? 36 let cursor: String?
@@ -52,6 +58,22 @@ private struct UpdatedStatusEvent: Decodable, Sendable {
52 let eventType: String 58 let eventType: String
53} 59}
54 60
61private struct TicketSubscriptionResponse: Decodable, Sendable {
62 let subscription: SubscriptionIdPayload
63}
64
65private struct UpdateTicketResponse: Decodable, Sendable {
66 let updateTicket: TicketIdPayload
67}
68
69private struct DeleteTicketResponse: Decodable, Sendable {
70 let deleteTicket: TicketIdPayload
71}
72
73private struct TicketIdPayload: Decodable, Sendable {
74 let id: Int
75}
76
55private struct AssignUserResponse: Decodable, Sendable { 77private struct AssignUserResponse: Decodable, Sendable {
56 let assignUser: MutationEventResponse 78 let assignUser: MutationEventResponse
57} 79}
@@ -112,6 +134,9 @@ final class TicketDetailViewModel {
112 private(set) var isLoading = false 134 private(set) var isLoading = false
113 private(set) var isSubmitting = false 135 private(set) var isSubmitting = false
114 private(set) var isPerformingAction = false 136 private(set) var isPerformingAction = false
137 /// Whether the authenticated user receives email for this ticket. Mirrors
138 /// `Ticket.subscription`, which is null when not subscribed.
139 private(set) var isSubscribed = false
115 private(set) var trackerLabels: [TicketLabel] = [] 140 private(set) var trackerLabels: [TicketLabel] = []
116 private(set) var rawTicketResponse: String? 141 private(set) var rawTicketResponse: String?
117 private(set) var cacheMetadata: CacheEntryMetadata? 142 private(set) var cacheMetadata: CacheEntryMetadata?
@@ -141,6 +166,35 @@ final class TicketDetailViewModel {
141 return input 166 return input
142 } 167 }
143 168
169 /// Builds an `UpdateTicketInput` carrying only the fields that changed, so an
170 /// edit never overwrites a field the user did not touch.
171 static func ticketUpdateInput(
172 subject: String,
173 body: String,
174 currentSubject: String,
175 currentBody: String?
176 ) -> [String: any Sendable] {
177 var input: [String: any Sendable] = [:]
178 let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
179 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
180
181 if trimmedSubject != currentSubject {
182 input["subject"] = trimmedSubject
183 }
184
185 if trimmedBody != (currentBody ?? "") {
186 if trimmedBody.isEmpty {
187 // A nil subscript assignment would drop the key and leave the old
188 // body in place instead of clearing it.
189 input.updateValue(Optional<String>.none as any Sendable, forKey: "body")
190 } else {
191 input["body"] = trimmedBody
192 }
193 }
194
195 return input
196 }
197
144 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) { 198 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
145 self.ownerUsername = ownerUsername 199 self.ownerUsername = ownerUsername
146 self.trackerName = trackerName 200 self.trackerName = trackerName
@@ -164,6 +218,7 @@ final class TicketDetailViewModel {
164 status 218 status
165 resolution 219 resolution
166 authenticity 220 authenticity
221 subscription { id }
167 submitter { canonicalName } 222 submitter { canonicalName }
168 assignees { canonicalName } 223 assignees { canonicalName }
169 labels { id name backgroundColor foregroundColor } 224 labels { id name backgroundColor foregroundColor }
@@ -233,6 +288,30 @@ final class TicketDetailViewModel {
233 } 288 }
234 """ 289 """
235 290
291 private static let updateTicketMutation = """
292 mutation updateTicket($trackerId: Int!, $ticketId: Int!, $input: UpdateTicketInput!) {
293 updateTicket(trackerId: $trackerId, ticketId: $ticketId, input: $input) { id }
294 }
295 """
296
297 private static let deleteTicketMutation = """
298 mutation deleteTicket($trackerId: Int!, $ticketId: Int!) {
299 deleteTicket(trackerId: $trackerId, ticketId: $ticketId) { id }
300 }
301 """
302
303 private static let ticketSubscribeMutation = """
304 mutation ticketSubscribe($trackerId: Int!, $ticketId: Int!) {
305 subscription: ticketSubscribe(trackerId: $trackerId, ticketId: $ticketId) { id }
306 }
307 """
308
309 private static let ticketUnsubscribeMutation = """
310 mutation ticketUnsubscribe($trackerId: Int!, $ticketId: Int!) {
311 subscription: ticketUnsubscribe(trackerId: $trackerId, ticketId: $ticketId) { id }
312 }
313 """
314
236 private static let assignUserMutation = """ 315 private static let assignUserMutation = """
237 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { 316 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
238 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } 317 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
@@ -420,6 +499,99 @@ final class TicketDetailViewModel {
420 isPerformingAction = false 499 isPerformingAction = false
421 } 500 }
422 501
502 /// Edits the ticket's subject and body. Returns true when the edit was sent,
503 /// including the no-op case where nothing changed.
504 @discardableResult
505 func updateTicket(subject: String, body: String) async -> Bool {
506 guard !isPerformingAction, let ticket else { return false }
507
508 let input = Self.ticketUpdateInput(
509 subject: subject,
510 body: body,
511 currentSubject: ticket.title,
512 currentBody: ticket.description
513 )
514 guard !input.isEmpty else { return true }
515
516 isPerformingAction = true
517 error = nil
518 defer { isPerformingAction = false }
519
520 do {
521 _ = try await client.execute(
522 service: .todo,
523 query: Self.updateTicketMutation,
524 variables: [
525 "trackerId": trackerId,
526 "ticketId": ticketId,
527 "input": input
528 ],
529 responseType: UpdateTicketResponse.self
530 )
531 await invalidateAfterMutation()
532 await reloadTicketPreservingDebugState()
533 return true
534 } catch {
535 self.error = error.userFacingMessage
536 return false
537 }
538 }
539
540 /// Subscribes to or unsubscribes from email notifications for this ticket.
541 func toggleSubscription() async {
542 guard !isPerformingAction else { return }
543 isPerformingAction = true
544 error = nil
545 defer { isPerformingAction = false }
546
547 let wasSubscribed = isSubscribed
548 // Reflect the change immediately; the catch below puts it back if the
549 // mutation fails, so the control never lies about server state.
550 isSubscribed.toggle()
551
552 do {
553 _ = try await client.execute(
554 service: .todo,
555 query: wasSubscribed ? Self.ticketUnsubscribeMutation : Self.ticketSubscribeMutation,
556 variables: [
557 "trackerId": trackerId,
558 "ticketId": ticketId
559 ],
560 responseType: TicketSubscriptionResponse.self
561 )
562 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
563 } catch {
564 isSubscribed = wasSubscribed
565 self.error = error.userFacingMessage
566 }
567 }
568
569 /// Deletes the ticket. Returns true on success so the caller can pop the view.
570 @discardableResult
571 func deleteTicket() async -> Bool {
572 guard !isPerformingAction else { return false }
573 isPerformingAction = true
574 error = nil
575 defer { isPerformingAction = false }
576
577 do {
578 _ = try await client.execute(
579 service: .todo,
580 query: Self.deleteTicketMutation,
581 variables: [
582 "trackerId": trackerId,
583 "ticketId": ticketId
584 ],
585 responseType: DeleteTicketResponse.self
586 )
587 await invalidateAfterMutation()
588 return true
589 } catch {
590 self.error = error.userFacingMessage
591 return false
592 }
593 }
594
423 func assignUser(username: String) async { 595 func assignUser(username: String) async {
424 guard !isPerformingAction else { return } 596 guard !isPerformingAction else { return }
425 isPerformingAction = true 597 isPerformingAction = true
@@ -691,6 +863,7 @@ final class TicketDetailViewModel {
691 labels: payload.labels 863 labels: payload.labels
692 ) 864 )
693 ticket = updatedTicket 865 ticket = updatedTicket
866 isSubscribed = payload.subscription != nil
694 let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder) 867 let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder)
695 events = updatedEvents 868 events = updatedEvents
696 } 869 }
Hutch/Views/Tickets/TicketListView.swift +15
@@ -242,6 +242,7 @@ struct TicketListView: View {
242 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) 242 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
243 await vm.loadTickets() 243 await vm.loadTickets()
244 await vm.loadTrackerLabels() 244 await vm.loadTrackerLabels()
245 await vm.loadSubscriptionState()
245 } 246 }
246 } 247 }
247 } 248 }
@@ -469,6 +470,20 @@ struct TicketListView: View {
469 470
470 Divider() 471 Divider()
471 472
473 if let viewModel {
474 Button {
475 Task { await viewModel.toggleSubscription() }
476 } label: {
477 Label(
478 viewModel.isSubscribed ? "Unsubscribe" : "Subscribe",
479 systemImage: viewModel.isSubscribed ? "bell.slash" : "bell"
480 )
481 }
482 .disabled(viewModel.isPerformingAction)
483 }
484
485 Divider()
486
472 if let trackerURL = SRHTWebURL.tracker(tracker) { 487 if let trackerURL = SRHTWebURL.tracker(tracker) {
473 Button { 488 Button {
474 openURL(trackerURL) 489 openURL(trackerURL)
Hutch/Views/Tickets/TicketListViewModel.swift +88
@@ -33,6 +33,23 @@ private struct LabelMutationResponse: Decodable, Sendable {
33 let unlabelTicket: EventRef? 33 let unlabelTicket: EventRef?
34} 34}
35 35
36private struct TrackerSubscriptionStateResponse: Decodable, Sendable {
37 let tracker: TrackerSubscriptionWrapper
38}
39
40private struct TrackerSubscriptionWrapper: Decodable, Sendable {
41 /// Null when the authenticated user is not subscribed to this tracker.
42 let subscription: TrackerSubscriptionIdPayload?
43}
44
45private struct TrackerSubscriptionResponse: Decodable, Sendable {
46 let subscription: TrackerSubscriptionIdPayload
47}
48
49private struct TrackerSubscriptionIdPayload: Decodable, Sendable {
50 let id: Int
51}
52
36private struct TrackerLabelsResponse: Decodable, Sendable { 53private struct TrackerLabelsResponse: Decodable, Sendable {
37 let tracker: TrackerLabelsWrapper 54 let tracker: TrackerLabelsWrapper
38} 55}
@@ -90,6 +107,9 @@ final class TicketListViewModel {
90 private(set) var isLoadingMore = false 107 private(set) var isLoadingMore = false
91 private(set) var isCreatingTicket = false 108 private(set) var isCreatingTicket = false
92 private(set) var isPerformingAction = false 109 private(set) var isPerformingAction = false
110 /// Whether the authenticated user receives email for this tracker. Mirrors
111 /// `Tracker.subscription`, which is null when not subscribed.
112 private(set) var isSubscribed = false
93 private(set) var trackerLabels: [TicketLabel] = [] 113 private(set) var trackerLabels: [TicketLabel] = []
94 private(set) var recentSearches: [ScopedSearchHistoryEntry] 114 private(set) var recentSearches: [ScopedSearchHistoryEntry]
95 private(set) var savedFilters: [SavedTicketFilter] 115 private(set) var savedFilters: [SavedTicketFilter]
@@ -216,6 +236,28 @@ final class TicketListViewModel {
216 } 236 }
217 """ 237 """
218 238
239 /// Kept separate from `query` above, which is paginated and cached — the
240 /// subscription is per-user state and should not ride along in page payloads.
241 private static let trackerSubscriptionQuery = """
242 query trackerSubscription($rid: ID!) {
243 tracker(rid: $rid) {
244 subscription { id }
245 }
246 }
247 """
248
249 private static let trackerSubscribeMutation = """
250 mutation trackerSubscribe($trackerId: Int!) {
251 subscription: trackerSubscribe(trackerId: $trackerId) { id }
252 }
253 """
254
255 private static let trackerUnsubscribeMutation = """
256 mutation trackerUnsubscribe($trackerId: Int!, $tickets: Boolean!) {
257 subscription: trackerUnsubscribe(trackerId: $trackerId, tickets: $tickets) { id }
258 }
259 """
260
219 private static let trackerLabelsQuery = """ 261 private static let trackerLabelsQuery = """
220 query trackerLabels($rid: ID!) { 262 query trackerLabels($rid: ID!) {
221 tracker(rid: $rid) { 263 tracker(rid: $rid) {
@@ -520,6 +562,52 @@ final class TicketListViewModel {
520 isPerformingAction = false 562 isPerformingAction = false
521 } 563 }
522 564
565 /// Reads whether the user is subscribed to this tracker. Uncached: it is
566 /// per-user state that must be accurate the moment the menu opens.
567 func loadSubscriptionState() async {
568 do {
569 let response = try await client.execute(
570 service: .todo,
571 query: Self.trackerSubscriptionQuery,
572 variables: ["rid": trackerRid],
573 responseType: TrackerSubscriptionStateResponse.self
574 )
575 isSubscribed = response.tracker.subscription != nil
576 } catch {
577 // Leave the last known value alone; the toggle reports its own errors.
578 }
579 }
580
581 /// Subscribes to or unsubscribes from email notifications for this tracker.
582 /// Unsubscribing leaves individual ticket subscriptions intact.
583 func toggleSubscription() async {
584 guard !isPerformingAction else { return }
585 isPerformingAction = true
586 error = nil
587 defer { isPerformingAction = false }
588
589 let wasSubscribed = isSubscribed
590 isSubscribed.toggle()
591
592 var variables: [String: any Sendable] = ["trackerId": trackerId]
593 if wasSubscribed {
594 variables["tickets"] = false
595 }
596
597 do {
598 _ = try await client.execute(
599 service: .todo,
600 query: wasSubscribed ? Self.trackerUnsubscribeMutation : Self.trackerSubscribeMutation,
601 variables: variables,
602 responseType: TrackerSubscriptionResponse.self
603 )
604 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
605 } catch {
606 isSubscribed = wasSubscribed
607 self.error = error.userFacingMessage
608 }
609 }
610
523 func loadTrackerLabels() async { 611 func loadTrackerLabels() async {
524 do { 612 do {
525 let cached = try await client.executeCached( 613 let cached = try await client.executeCached(
HutchTests/TicketDetailViewModelTests.swift +69
@@ -27,4 +27,73 @@ struct TicketDetailViewModelTests {
27 #expect(input["status"] as? String == TicketStatus.resolved.rawValue) 27 #expect(input["status"] as? String == TicketStatus.resolved.rawValue)
28 #expect(input["resolution"] as? String == TicketResolution.fixed.rawValue) 28 #expect(input["resolution"] as? String == TicketResolution.fixed.rawValue)
29 } 29 }
30
31 @Test
32 @MainActor
33 func ticketUpdateInputOmitsUnchangedFields() {
34 let input = TicketDetailViewModel.ticketUpdateInput(
35 subject: "Same subject",
36 body: "Same body",
37 currentSubject: "Same subject",
38 currentBody: "Same body"
39 )
40
41 #expect(input.isEmpty)
42 }
43
44 @Test
45 @MainActor
46 func ticketUpdateInputCarriesOnlyTheChangedField() {
47 let input = TicketDetailViewModel.ticketUpdateInput(
48 subject: "New subject",
49 body: "Same body",
50 currentSubject: "Old subject",
51 currentBody: "Same body"
52 )
53
54 #expect(input["subject"] as? String == "New subject")
55 #expect(!input.keys.contains("body"))
56 }
57
58 @Test
59 @MainActor
60 func ticketUpdateInputTrimsWhitespaceBeforeComparing() {
61 let input = TicketDetailViewModel.ticketUpdateInput(
62 subject: " Same subject ",
63 body: "\n Same body \n",
64 currentSubject: "Same subject",
65 currentBody: "Same body"
66 )
67
68 #expect(input.isEmpty)
69 }
70
71 @Test
72 @MainActor
73 func ticketUpdateInputUsesNilToClearBody() {
74 let input = TicketDetailViewModel.ticketUpdateInput(
75 subject: "Same subject",
76 body: " ",
77 currentSubject: "Same subject",
78 currentBody: "Existing body"
79 )
80
81 // The key must survive with a nil value so it encodes as a JSON null and
82 // actually clears the body, rather than being dropped from the mutation.
83 #expect(input.keys.contains("body"))
84 #expect(input["body"] as? String == nil)
85 }
86
87 @Test
88 @MainActor
89 func ticketUpdateInputTreatsNilBodyAsEmpty() {
90 let input = TicketDetailViewModel.ticketUpdateInput(
91 subject: "Same subject",
92 body: "",
93 currentSubject: "Same subject",
94 currentBody: nil
95 )
96
97 #expect(input.isEmpty)
98 }
30} 99}
ROADMAP.md +37 −29
@@ -36,35 +36,43 @@ been running only on demand in Xcode, and ten had rotted:
36 and inbox threads keyed `id` on a subject-derived grouping key so two threads 36 and inbox threads keyed `id` on a subject-derived grouping key so two threads
37 sharing a subject on one list collided under `Identifiable`. 37 sharing a subject on one list collided under `Identifiable`.
38 38
39## Phase 1: Close the write gaps 39## Phase 1: Close the write gaps — done (v3.6.0)
40 40
41Small, independently shippable mutations that already exist in the API but are 41Small, independently shippable mutations that already existed in the API but
42never called. Each removes a "why can't I do this here?" moment. 42were never called. Each removes a "why can't I do this here?" moment.
43 43
44- `updateTicket` — edit ticket title and description after creation. Currently 44- ~~`updateTicket`~~ — edit a ticket's subject and body after creation.
45 a ticket can be created and its status changed, but never edited. 45- ~~`deleteTicket`~~ — delete a ticket, behind a confirmation.
46- `deleteTicket` — delete a ticket. 46- ~~`ticketSubscribe` / `ticketUnsubscribe`, `trackerSubscribe` /
47- `trackerSubscribe` / `trackerUnsubscribe`, `ticketSubscribe` / 47 `trackerUnsubscribe`~~ — `Ticket.subscription` and `Tracker.subscription` are
48 `ticketUnsubscribe`, `mailingListSubscribe` / `mailingListUnsubscribe` — 48 null when not subscribed, so both toggles reflect real server state.
49 subscriptions are currently read-only. `MailingListListView` reads the 49- ~~`mailingListUnsubscribe`~~ — see the caveat below.
50 `subscriptions` query, but nothing can subscribe or unsubscribe. 50- ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and
51- `updatePreferences` (todo.sr.ht and lists.sr.ht) — email notification 51 `copySelf`, surfaced as an Email section in Settings.
52 preferences. 52
53 53`mailingListSubscribe` is deliberately not wired up. `MailingList` has no
54### Refactors to fold in 54`subscription` field, unlike `Ticket` and `Tracker`, so per-list state is only
55 55knowable from the `subscriptions` query — which by definition lists what the
56These are touched by everything in later phases, so they belong here rather 56user is already subscribed to. Subscribing needs a list the user is *not*
57than as standalone work. 57subscribed to, and sr.ht exposes no discovery API to find one (see
58 58[SCOPE.md](SCOPE.md) on hub.sr.ht). Revisit if hub.sr.ht ever gains an API, or
59- `SRHTClient` has four near-identical request-and-decode paths (`execute`, 59alongside Phase 2, which surfaces lists through patchsets.
60 `executeMultipart`, `executeMultipartFiles`, `executeAndCache`, plus the 60
61 private `performGraphQLRequest`). The token guard, header setup, status-code 61### Refactors folded in
62 handling, and a ~35-line `#if DEBUG` logging block are each duplicated about 62
63 five times. Collapse to one request builder and one decode helper. 63- ~~Collapse `SRHTClient`'s duplicated request paths~~. Extracted
64- Two caches overlap: the in-memory `responseCache` and the persistent `cache`, 64 `makeAuthorizedRequest`, `send`, and `encodedGraphQLBody`; `executeMultipart`
65 reached through two different `executeCached` overloads with different return 65 became the single-file case of `executeMultipartFiles`. The `#if DEBUG`
66 types and semantics (one does stale-while-revalidate with TTLs, the other only 66 logging block went from five copies to one. 938 lines to 569.
67 checks memory). Unify on the TTL-aware path. 67- ~~Unify the two `executeCached` overloads~~. The memory-only overload and
68 `executeAndCache` turned out to be dead — all 38 call sites already used the
69 TTL-aware path — so both were removed rather than merged. `responseCache`
70 remains as the in-memory layer behind `cachedPayload`.
71
72Known follow-up: `BuildListViewModel`, `RepositoryListViewModel`, and
73`PasteService` still read `client.responseCache` directly, falling back across
74two different cache keys. That predates `APICacheKeys` and should be folded into
75`cachedPayload`.
68 76
69## Phase 2: Patchsets 77## Phase 2: Patchsets
70 78