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 @@
597597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
598598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
599599 CODE_SIGN_STYLE = Automatic;
600 CURRENT_PROJECT_VERSION = 87;
600 CURRENT_PROJECT_VERSION = 88;
601601 DEVELOPMENT_TEAM = ZCNAX3VL9D;
602602 ENABLE_PREVIEWS = YES;
603603 GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
614614 "$(inherited)",
615615 "@executable_path/Frameworks",
616616 );
617 MARKETING_VERSION = 3.5.0;
617 MARKETING_VERSION = 3.6.0;
618618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
619619 PRODUCT_NAME = "$(TARGET_NAME)";
620620 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
634634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
635635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
636636 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 87;
637 CURRENT_PROJECT_VERSION = 88;
638638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639639 ENABLE_PREVIEWS = YES;
640640 GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
651651 "$(inherited)",
652652 "@executable_path/Frameworks",
653653 );
654 MARKETING_VERSION = 3.5.0;
654 MARKETING_VERSION = 3.6.0;
655655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
656656 PRODUCT_NAME = "$(TARGET_NAME)";
657657 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
714714 APPLICATION_EXTENSION_API_ONLY = YES;
715715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
716716 CODE_SIGN_STYLE = Automatic;
717 CURRENT_PROJECT_VERSION = 87;
717 CURRENT_PROJECT_VERSION = 88;
718718 DEVELOPMENT_TEAM = ZCNAX3VL9D;
719719 GENERATE_INFOPLIST_FILE = NO;
720720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
724724 "@executable_path/Frameworks",
725725 "@executable_path/../../Frameworks",
726726 );
727 MARKETING_VERSION = 3.5.0;
727 MARKETING_VERSION = 3.6.0;
728728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
729729 PRODUCT_NAME = "$(TARGET_NAME)";
730730 SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
743743 APPLICATION_EXTENSION_API_ONLY = YES;
744744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
745745 CODE_SIGN_STYLE = Automatic;
746 CURRENT_PROJECT_VERSION = 87;
746 CURRENT_PROJECT_VERSION = 88;
747747 DEVELOPMENT_TEAM = ZCNAX3VL9D;
748748 GENERATE_INFOPLIST_FILE = NO;
749749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
753753 "@executable_path/Frameworks",
754754 "@executable_path/../../Frameworks",
755755 );
756 MARKETING_VERSION = 3.5.0;
756 MARKETING_VERSION = 3.6.0;
757757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
758758 PRODUCT_NAME = "$(TARGET_NAME)";
759759 SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
772772 APPLICATION_EXTENSION_API_ONLY = YES;
773773 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
774774 CODE_SIGN_STYLE = Automatic;
775 CURRENT_PROJECT_VERSION = 87;
775 CURRENT_PROJECT_VERSION = 88;
776776 DEVELOPMENT_TEAM = ZCNAX3VL9D;
777777 GENERATE_INFOPLIST_FILE = NO;
778778 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
782782 "@executable_path/Frameworks",
783783 "@executable_path/../../Frameworks",
784784 );
785 MARKETING_VERSION = 3.5.0;
785 MARKETING_VERSION = 3.6.0;
786786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
787787 PRODUCT_NAME = "$(TARGET_NAME)";
788788 SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
801801 APPLICATION_EXTENSION_API_ONLY = YES;
802802 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
803803 CODE_SIGN_STYLE = Automatic;
804 CURRENT_PROJECT_VERSION = 87;
804 CURRENT_PROJECT_VERSION = 88;
805805 DEVELOPMENT_TEAM = ZCNAX3VL9D;
806806 GENERATE_INFOPLIST_FILE = NO;
807807 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
811811 "@executable_path/Frameworks",
812812 "@executable_path/../../Frameworks",
813813 );
814 MARKETING_VERSION = 3.5.0;
814 MARKETING_VERSION = 3.6.0;
815815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
816816 PRODUCT_NAME = "$(TARGET_NAME)";
817817 SKIP_INSTALL = YES;
Hutch/Networking/SRHTClient.swift +43 −412
@@ -69,101 +69,8 @@ final class SRHTClient: Sendable {
6969 variables: [String: any Sendable]? = nil,
7070 responseType _: T.Type
7171 ) async throws -> T {
72 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
73 throw SRHTError.unauthorized
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
72 let data = try await performGraphQLRequest(service: service, query: query, variables: variables)
73 return try decodeGraphQLData(data, service: service, query: query, variables: variables)
16774 }
16875
16976 func executeCached<T: Decodable>(
@@ -308,132 +215,13 @@ final class SRHTClient: Sendable {
308215 file: MultipartUploadFile,
309216 responseType _: T.Type
310217 ) async throws -> T {
311 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
312 throw SRHTError.unauthorized
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(
218 try await executeMultipartFiles(
219 service: service,
325220 query: query,
326 variables: variables.mapValues { AnyCodable($0) }
221 variables: variables,
222 files: [file],
223 responseType: T.self
327224 )
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
437225 }
438226
439227 func executeMultipartFiles<T: Decodable>(
@@ -443,23 +231,13 @@ final class SRHTClient: Sendable {
443231 files: [MultipartUploadFile],
444232 responseType _: T.Type
445233 ) async throws -> T {
446 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
447 throw SRHTError.unauthorized
448 }
449
450234 let boundary = "Boundary-\(UUID().uuidString)"
451
452 var request = URLRequest(url: service.url)
453 request.httpMethod = "POST"
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) }
235 var request = try makeAuthorizedRequest(
236 service: service,
237 contentType: "multipart/form-data; boundary=\(boundary)"
461238 )
462 let operationsData = try encoder.encode(operationsBody)
239
240 let operationsData = try encodedGraphQLBody(query: query, variables: variables)
463241
464242 let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in
465243 (String(index), ["variables.\(file.variablePath)"])
@@ -491,174 +269,10 @@ final class SRHTClient: Sendable {
491269 body.append("--\(boundary)--\r\n")
492270 request.httpBody = body
493271
494 let (data, response): (Data, URLResponse)
495 do {
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
272 let data = try await send(request)
273 return try decodeGraphQLData(data, service: service, query: query, variables: variables)
531274 }
532275
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 }
662276
663277 // MARK: - Plain-text fetch
664278
@@ -748,11 +362,9 @@ final class SRHTClient: Sendable {
748362// MARK: - Data Helper
749363
750364private extension SRHTClient {
751 func performGraphQLRequest(
752 service: SRHTService,
753 query: String,
754 variables: [String: any Sendable]?
755 ) async throws -> Data {
365 /// Builds an authorized POST for `service`. Throws ``SRHTError/unauthorized``
366 /// when no token is set, so callers never have to guard separately.
367 func makeAuthorizedRequest(service: SRHTService, contentType: String) throws -> URLRequest {
756368 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
757369 throw SRHTError.unauthorized
758370 }
@@ -761,14 +373,14 @@ private extension SRHTClient {
761373 request.httpMethod = "POST"
762374 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
763375 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
764 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
765
766 let body = GraphQLRequestBody(
767 query: query,
768 variables: variables?.mapValues { AnyCodable($0) }
769 )
770 request.httpBody = try encoder.encode(body)
376 request.setValue(contentType, forHTTPHeaderField: "Content-Type")
377 return request
378 }
771379
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 {
772384 let (data, response): (Data, URLResponse)
773385 do {
774386 (data, response) = try await session.data(for: request)
@@ -790,6 +402,25 @@ private extension SRHTClient {
790402 return data
791403 }
792404
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
793424 func decodeGraphQLData<T: Decodable>(
794425 _ data: Data,
795426 service: SRHTService,
Hutch/Views/Lists/MailingListListView.swift +67
@@ -5,6 +5,7 @@ import SwiftUI
55final class MailingListListViewModel {
66 private(set) var mailingLists: [InboxMailingListReference] = []
77 private(set) var isLoading = false
8 private(set) var isPerformingAction = false
89 var error: String?
910 var searchText = ""
1011
@@ -28,10 +29,51 @@ final class MailingListListViewModel {
2829 }
2930 """
3031
32 private static let unsubscribeMutation = """
33 mutation mailingListUnsubscribe($listID: Int!) {
34 subscription: mailingListUnsubscribe(listID: $listID) { id }
35 }
36 """
37
3138 init(client: SRHTClient) {
3239 self.client = client
3340 }
3441
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
3577 var filteredMailingLists: [InboxMailingListReference] {
3678 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
3779 guard !q.isEmpty else { return mailingLists }
@@ -106,6 +148,7 @@ final class MailingListListViewModel {
106148struct MailingListListView: View {
107149 @Environment(AppState.self) private var appState
108150 @State private var viewModel: MailingListListViewModel?
151 @State private var pendingUnsubscribe: InboxMailingListReference?
109152
110153 var body: some View {
111154 Group {
@@ -141,6 +184,14 @@ struct MailingListListView: View {
141184 }
142185 .padding(.vertical, 2)
143186 }
187 .swipeActions(edge: .trailing) {
188 Button {
189 pendingUnsubscribe = mailingList
190 } label: {
191 SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
192 }
193 .tint(.orange)
194 }
144195 }
145196 .themedRow()
146197 }
@@ -151,6 +202,22 @@ struct MailingListListView: View {
151202 placement: .navigationBarDrawer(displayMode: .always),
152203 prompt: "Search lists"
153204 )
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 }
154221 .overlay {
155222 if viewModel.isLoading, viewModel.mailingLists.isEmpty {
156223 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 {
1010 private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
1111 @State private var pendingDestructiveAction: SettingsDestructiveAction?
1212 @State private var showAccountSwitcher = false
13 @State private var preferences: NotificationPreferencesViewModel?
1314
1415 var body: some View {
1516 Form {
1617 appearanceSection()
1718 behaviorSection()
19 emailSection()
1820 safariExtensionSection()
1921 authenticationSection()
2022 }
2123 .themedList()
2224 .navigationTitle("Settings")
25 .task {
26 let viewModel = preferences ?? NotificationPreferencesViewModel(client: appState.client)
27 preferences = viewModel
28 await viewModel.loadIfNeeded()
29 }
2330 .sheet(isPresented: $showAccountSwitcher) {
2431 AccountSwitcherView()
2532 }
@@ -117,6 +124,51 @@ struct SettingsView: View {
117124 }
118125 }
119126
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
120172 @ViewBuilder
121173 private func authenticationSection() -> some View {
122174 Section {
Hutch/Views/Tickets/TicketDetailView.swift +133
@@ -11,12 +11,15 @@ struct TicketDetailView: View {
1111 @Environment(AppState.self) private var appState
1212 @Environment(\.colorScheme) private var colorScheme
1313 @Environment(\.openURL) private var openURL
14 @Environment(\.dismiss) private var dismiss
1415 @State private var viewModel: TicketDetailViewModel?
1516
1617 // Sheet state
1718 @State private var showResolveSheet = false
1819 @State private var showAssignSheet = false
1920 @State private var showLabelsSheet = false
21 @State private var showEditSheet = false
22 @State private var showDeleteConfirmation = false
2023 @State private var isOpeningTracker = false
2124
2225 // Comment composer mode
@@ -100,9 +103,30 @@ struct TicketDetailView: View {
100103 SwiftUI.Label("Copy Tracker RID", systemImage: "number")
101104 }
102105
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
103119 if isOwnedByCurrentUser {
104120 Divider()
105121
122 if viewModel.ticket != nil {
123 Button {
124 showEditSheet = true
125 } label: {
126 SwiftUI.Label("Edit Ticket", systemImage: "square.and.pencil")
127 }
128 }
129
106130 if let ticket = viewModel.ticket {
107131 if ticket.status == .resolved {
108132 Button {
@@ -133,6 +157,14 @@ struct TicketDetailView: View {
133157 } label: {
134158 SwiftUI.Label("Manage Labels", systemImage: "tag")
135159 }
160
161 Divider()
162
163 Button(role: .destructive) {
164 showDeleteConfirmation = true
165 } label: {
166 SwiftUI.Label("Delete Ticket", systemImage: "trash")
167 }
136168 }
137169 } label: {
138170 Image(systemName: "ellipsis.circle")
@@ -150,6 +182,32 @@ struct TicketDetailView: View {
150182 LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
151183 .presentationDetents([.medium])
152184 }
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 }
153211 }
154212
155213 // MARK: - Detail Content
@@ -724,6 +782,81 @@ private struct EventRow: View {
724782
725783// MARK: - Resolve Sheet
726784
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
727860private struct ResolveSheet: View {
728861 let viewModel: TicketDetailViewModel
729862 @Binding var isPresented: Bool
Hutch/Views/Tickets/TicketDetailViewModel.swift +173
@@ -19,12 +19,18 @@ private struct TicketDetailPayload: Decodable, Sendable {
1919 let status: TicketStatus
2020 let resolution: TicketResolution?
2121 let authenticity: Authenticity
22 /// Null when the authenticated user is not subscribed to this ticket.
23 let subscription: SubscriptionIdPayload?
2224 let submitter: Entity
2325 let assignees: [Entity]
2426 let labels: [TicketLabel]
2527 let events: EventsPage
2628}
2729
30struct SubscriptionIdPayload: Decodable, Sendable {
31 let id: Int
32}
33
2834private struct EventsPage: Decodable, Sendable {
2935 let results: [TicketEvent]
3036 let cursor: String?
@@ -52,6 +58,22 @@ private struct UpdatedStatusEvent: Decodable, Sendable {
5258 let eventType: String
5359}
5460
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
5577private struct AssignUserResponse: Decodable, Sendable {
5678 let assignUser: MutationEventResponse
5779}
@@ -112,6 +134,9 @@ final class TicketDetailViewModel {
112134 private(set) var isLoading = false
113135 private(set) var isSubmitting = false
114136 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
115140 private(set) var trackerLabels: [TicketLabel] = []
116141 private(set) var rawTicketResponse: String?
117142 private(set) var cacheMetadata: CacheEntryMetadata?
@@ -141,6 +166,35 @@ final class TicketDetailViewModel {
141166 return input
142167 }
143168
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
144198 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
145199 self.ownerUsername = ownerUsername
146200 self.trackerName = trackerName
@@ -164,6 +218,7 @@ final class TicketDetailViewModel {
164218 status
165219 resolution
166220 authenticity
221 subscription { id }
167222 submitter { canonicalName }
168223 assignees { canonicalName }
169224 labels { id name backgroundColor foregroundColor }
@@ -233,6 +288,30 @@ final class TicketDetailViewModel {
233288 }
234289 """
235290
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
236315 private static let assignUserMutation = """
237316 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
238317 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
@@ -420,6 +499,99 @@ final class TicketDetailViewModel {
420499 isPerformingAction = false
421500 }
422501
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
423595 func assignUser(username: String) async {
424596 guard !isPerformingAction else { return }
425597 isPerformingAction = true
@@ -691,6 +863,7 @@ final class TicketDetailViewModel {
691863 labels: payload.labels
692864 )
693865 ticket = updatedTicket
866 isSubscribed = payload.subscription != nil
694867 let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder)
695868 events = updatedEvents
696869 }
Hutch/Views/Tickets/TicketListView.swift +15
@@ -242,6 +242,7 @@ struct TicketListView: View {
242242 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
243243 await vm.loadTickets()
244244 await vm.loadTrackerLabels()
245 await vm.loadSubscriptionState()
245246 }
246247 }
247248 }
@@ -469,6 +470,20 @@ struct TicketListView: View {
469470
470471 Divider()
471472
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
472487 if let trackerURL = SRHTWebURL.tracker(tracker) {
473488 Button {
474489 openURL(trackerURL)
Hutch/Views/Tickets/TicketListViewModel.swift +88
@@ -33,6 +33,23 @@ private struct LabelMutationResponse: Decodable, Sendable {
3333 let unlabelTicket: EventRef?
3434}
3535
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
3653private struct TrackerLabelsResponse: Decodable, Sendable {
3754 let tracker: TrackerLabelsWrapper
3855}
@@ -90,6 +107,9 @@ final class TicketListViewModel {
90107 private(set) var isLoadingMore = false
91108 private(set) var isCreatingTicket = false
92109 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
93113 private(set) var trackerLabels: [TicketLabel] = []
94114 private(set) var recentSearches: [ScopedSearchHistoryEntry]
95115 private(set) var savedFilters: [SavedTicketFilter]
@@ -216,6 +236,28 @@ final class TicketListViewModel {
216236 }
217237 """
218238
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
219261 private static let trackerLabelsQuery = """
220262 query trackerLabels($rid: ID!) {
221263 tracker(rid: $rid) {
@@ -520,6 +562,52 @@ final class TicketListViewModel {
520562 isPerformingAction = false
521563 }
522564
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
523611 func loadTrackerLabels() async {
524612 do {
525613 let cached = try await client.executeCached(
HutchTests/TicketDetailViewModelTests.swift +69
@@ -27,4 +27,73 @@ struct TicketDetailViewModelTests {
2727 #expect(input["status"] as? String == TicketStatus.resolved.rawValue)
2828 #expect(input["resolution"] as? String == TicketResolution.fixed.rawValue)
2929 }
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 }
3099}
ROADMAP.md +37 −29
@@ -36,35 +36,43 @@ been running only on demand in Xcode, and ten had rotted:
3636 and inbox threads keyed `id` on a subject-derived grouping key so two threads
3737 sharing a subject on one list collided under `Identifiable`.
3838
39## Phase 1: Close the write gaps
40
41Small, independently shippable mutations that already exist in the API but are
42never called. Each removes a "why can't I do this here?" moment.
43
44- `updateTicket` — edit ticket title and description after creation. Currently
45 a ticket can be created and its status changed, but never edited.
46- `deleteTicket` — delete a ticket.
47- `trackerSubscribe` / `trackerUnsubscribe`, `ticketSubscribe` /
48 `ticketUnsubscribe`, `mailingListSubscribe` / `mailingListUnsubscribe` —
49 subscriptions are currently read-only. `MailingListListView` reads the
50 `subscriptions` query, but nothing can subscribe or unsubscribe.
51- `updatePreferences` (todo.sr.ht and lists.sr.ht) — email notification
52 preferences.
53
54### Refactors to fold in
55
56These are touched by everything in later phases, so they belong here rather
57than as standalone work.
58
59- `SRHTClient` has four near-identical request-and-decode paths (`execute`,
60 `executeMultipart`, `executeMultipartFiles`, `executeAndCache`, plus the
61 private `performGraphQLRequest`). The token guard, header setup, status-code
62 handling, and a ~35-line `#if DEBUG` logging block are each duplicated about
63 five times. Collapse to one request builder and one decode helper.
64- Two caches overlap: the in-memory `responseCache` and the persistent `cache`,
65 reached through two different `executeCached` overloads with different return
66 types and semantics (one does stale-while-revalidate with TTLs, the other only
67 checks memory). Unify on the TTL-aware path.
39## Phase 1: Close the write gaps — done (v3.6.0)
40
41Small, independently shippable mutations that already existed in the API but
42were never called. Each removes a "why can't I do this here?" moment.
43
44- ~~`updateTicket`~~ — edit a ticket's subject and body after creation.
45- ~~`deleteTicket`~~ — delete a ticket, behind a confirmation.
46- ~~`ticketSubscribe` / `ticketUnsubscribe`, `trackerSubscribe` /
47 `trackerUnsubscribe`~~ — `Ticket.subscription` and `Tracker.subscription` are
48 null when not subscribed, so both toggles reflect real server state.
49- ~~`mailingListUnsubscribe`~~ — see the caveat below.
50- ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and
51 `copySelf`, surfaced as an Email section in Settings.
52
53`mailingListSubscribe` is deliberately not wired up. `MailingList` has no
54`subscription` field, unlike `Ticket` and `Tracker`, so per-list state is only
55knowable from the `subscriptions` query — which by definition lists what the
56user is already subscribed to. Subscribing needs a list the user is *not*
57subscribed to, and sr.ht exposes no discovery API to find one (see
58[SCOPE.md](SCOPE.md) on hub.sr.ht). Revisit if hub.sr.ht ever gains an API, or
59alongside Phase 2, which surfaces lists through patchsets.
60
61### Refactors folded in
62
63- ~~Collapse `SRHTClient`'s duplicated request paths~~. Extracted
64 `makeAuthorizedRequest`, `send`, and `encodedGraphQLBody`; `executeMultipart`
65 became the single-file case of `executeMultipartFiles`. The `#if DEBUG`
66 logging block went from five copies to one. 938 lines to 569.
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`.
6876
6977## Phase 2: Patchsets
7078