summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-15 21:33:59 -0500
committerGitHub <[email protected]>2026-07-15 21:33:59 -0500
commitb9ec80716ea015de5b6b31395fdc5ff03191398c (patch)
treeb1badacbdc22a2fda80bb6a94c72eecd3b596431 /Hutch
parent87cf19bb18dd9423d4c1558700cc64898db07003 (diff)
downloadhutch-b9ec80716ea015de5b6b31395fdc5ff03191398c.tar.gz
hutch-b9ec80716ea015de5b6b31395fdc5ff03191398c.tar.bz2
hutch-b9ec80716ea015de5b6b31395fdc5ff03191398c.zip
Phase 1: close the write gaps (#3)v3.6.0
* 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 7ffef07. 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.
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/Networking/SRHTClient.swift455
-rw-r--r--Hutch/Views/Lists/MailingListListView.swift67
-rw-r--r--Hutch/Views/Settings/NotificationPreferencesViewModel.swift173
-rw-r--r--Hutch/Views/Settings/SettingsView.swift52
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift133
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift173
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift15
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift88
8 files changed, 744 insertions, 412 deletions
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
index 37fd6b8..94531f4 100644
--- a/Hutch/Networking/SRHTClient.swift
+++ b/Hutch/Networking/SRHTClient.swift
@@ -69,101 +69,8 @@ final class SRHTClient: Sendable {
variables: [String: any Sendable]? = nil,
responseType _: T.Type
) async throws -> T {
- guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
- throw SRHTError.unauthorized
- }
-
- // Build request
- var request = URLRequest(url: service.url)
- request.httpMethod = "POST"
- request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
- request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-
- let body = GraphQLRequestBody(
- query: query,
- variables: variables?.mapValues { AnyCodable($0) }
- )
- request.httpBody = try encoder.encode(body)
-
- // Execute
- let (data, response): (Data, URLResponse)
- do {
- (data, response) = try await session.data(for: request)
- } catch {
- throw SRHTError.networkError(error)
- }
-
- // Check HTTP status
- if let http = response as? HTTPURLResponse {
- if http.statusCode == 401 {
- throw SRHTError.unauthorized
- }
- if !(200...299).contains(http.statusCode) {
- try throwGraphQLErrorsIfPresent(in: data)
- throw SRHTError.httpError(http.statusCode)
- }
- }
-
- try throwGraphQLErrorsIfPresent(in: data)
-
- // Decode GraphQL response envelope
- let graphQLResponse: GraphQLResponse<T>
- do {
- graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
- } catch {
- #if DEBUG
- let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
- let variablesDescription = String(describing: variables)
- if let decodingError = error as? DecodingError {
- logger.error(
- """
- Decoding failed for \(String(describing: T.self), privacy: .public)
- service: \(service.rawValue, privacy: .public)
- query:
- \(query, privacy: .public)
- variables:
- \(variablesDescription, privacy: .public)
- decodingError:
- \(String(describing: decodingError), privacy: .public)
- response:
- \(responseBody, privacy: .public)
- """
- )
- } else {
- logger.error(
- """
- Decoding failed for \(String(describing: T.self), privacy: .public)
- service: \(service.rawValue, privacy: .public)
- query:
- \(query, privacy: .public)
- variables:
- \(variablesDescription, privacy: .public)
- error:
- \(String(describing: error), privacy: .public)
- response:
- \(responseBody, privacy: .public)
- """
- )
- }
- #else
- logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
- #endif
- throw SRHTError.decodingError(error)
- }
-
- // Surface GraphQL-level errors
- if let errors = graphQLResponse.errors, !errors.isEmpty {
- throw SRHTError.graphQLErrors(errors)
- }
-
- guard let result = graphQLResponse.data else {
- throw SRHTError.decodingError(
- DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
- )
- }
-
- return result
+ let data = try await performGraphQLRequest(service: service, query: query, variables: variables)
+ return try decodeGraphQLData(data, service: service, query: query, variables: variables)
}
func executeCached<T: Decodable>(
@@ -308,132 +215,13 @@ final class SRHTClient: Sendable {
file: MultipartUploadFile,
responseType _: T.Type
) async throws -> T {
- guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
- throw SRHTError.unauthorized
- }
-
- let boundary = "Boundary-\(UUID().uuidString)"
-
- var request = URLRequest(url: service.url)
- request.httpMethod = "POST"
- request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
- request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
-
- // Build the operations JSON (file variable mapped to null)
- let operationsBody = GraphQLRequestBody(
+ try await executeMultipartFiles(
+ service: service,
query: query,
- variables: variables.mapValues { AnyCodable($0) }
+ variables: variables,
+ files: [file],
+ responseType: T.self
)
- let operationsData = try encoder.encode(operationsBody)
-
- // Build the map JSON: { "0": ["variables.<variablePath>"] }
- let mapDict = ["0": ["variables.\(file.variablePath)"]]
- let mapData = try encoder.encode(mapDict)
-
- // Assemble multipart body
- var body = Data()
-
- // Part: operations
- body.append("--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
- body.append("Content-Type: application/json\r\n\r\n")
- body.append(operationsData)
- body.append("\r\n")
-
- // Part: map
- body.append("--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"map\"\r\n")
- body.append("Content-Type: application/json\r\n\r\n")
- body.append(mapData)
- body.append("\r\n")
-
- // Part: file
- body.append("--\(boundary)\r\n")
- body.append("Content-Disposition: form-data; name=\"0\"; filename=\"\(file.fileName)\"\r\n")
- body.append("Content-Type: \(file.mimeType)\r\n\r\n")
- body.append(file.fileData)
- body.append("\r\n")
-
- // Closing boundary
- body.append("--\(boundary)--\r\n")
-
- request.httpBody = body
-
- let (data, response): (Data, URLResponse)
- do {
- (data, response) = try await session.data(for: request)
- } catch {
- throw SRHTError.networkError(error)
- }
-
- if let http = response as? HTTPURLResponse {
- if http.statusCode == 401 {
- throw SRHTError.unauthorized
- }
- if !(200...299).contains(http.statusCode) {
- try throwGraphQLErrorsIfPresent(in: data)
- throw SRHTError.httpError(http.statusCode)
- }
- }
-
- try throwGraphQLErrorsIfPresent(in: data)
-
- let graphQLResponse: GraphQLResponse<T>
- do {
- graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
- } catch {
- #if DEBUG
- let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
- let variablesDescription = String(describing: variables)
- if let decodingError = error as? DecodingError {
- logger.error(
- """
- Decoding failed for \(String(describing: T.self), privacy: .public)
- service: \(service.rawValue, privacy: .public)
- query:
- \(query, privacy: .public)
- variables:
- \(variablesDescription, privacy: .public)
- decodingError:
- \(String(describing: decodingError), privacy: .public)
- response:
- \(responseBody, privacy: .public)
- """
- )
- } else {
- logger.error(
- """
- Decoding failed for \(String(describing: T.self), privacy: .public)
- service: \(service.rawValue, privacy: .public)
- query:
- \(query, privacy: .public)
- variables:
- \(variablesDescription, privacy: .public)
- error:
- \(String(describing: error), privacy: .public)
- response:
- \(responseBody, privacy: .public)
- """
- )
- }
- #else
- logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
- #endif
- throw SRHTError.decodingError(error)
- }
-
- if let errors = graphQLResponse.errors, !errors.isEmpty {
- throw SRHTError.graphQLErrors(errors)
- }
-
- guard let result = graphQLResponse.data else {
- throw SRHTError.decodingError(
- DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
- )
- }
-
- return result
}
func executeMultipartFiles<T: Decodable>(
@@ -443,23 +231,13 @@ final class SRHTClient: Sendable {
files: [MultipartUploadFile],
responseType _: T.Type
) async throws -> T {
- guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
- throw SRHTError.unauthorized
- }
-
let boundary = "Boundary-\(UUID().uuidString)"
-
- var request = URLRequest(url: service.url)
- request.httpMethod = "POST"
- request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
- request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
-
- let operationsBody = GraphQLRequestBody(
- query: query,
- variables: variables.mapValues { AnyCodable($0) }
+ var request = try makeAuthorizedRequest(
+ service: service,
+ contentType: "multipart/form-data; boundary=\(boundary)"
)
- let operationsData = try encoder.encode(operationsBody)
+
+ let operationsData = try encodedGraphQLBody(query: query, variables: variables)
let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in
(String(index), ["variables.\(file.variablePath)"])
@@ -491,174 +269,10 @@ final class SRHTClient: Sendable {
body.append("--\(boundary)--\r\n")
request.httpBody = body
- let (data, response): (Data, URLResponse)
- do {
- (data, response) = try await session.data(for: request)
- } catch {
- throw SRHTError.networkError(error)
- }
-
- if let http = response as? HTTPURLResponse {
- if http.statusCode == 401 {
- throw SRHTError.unauthorized
- }
- if !(200...299).contains(http.statusCode) {
- try throwGraphQLErrorsIfPresent(in: data)
- throw SRHTError.httpError(http.statusCode)
- }
- }
-
- try throwGraphQLErrorsIfPresent(in: data)
-
- let graphQLResponse: GraphQLResponse<T>
- do {
- graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
- } catch {
- throw SRHTError.decodingError(error)
- }
-
- if let errors = graphQLResponse.errors, !errors.isEmpty {
- throw SRHTError.graphQLErrors(errors)
- }
-
- guard let result = graphQLResponse.data else {
- throw SRHTError.decodingError(
- DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
- )
- }
-
- return result
+ let data = try await send(request)
+ return try decodeGraphQLData(data, service: service, query: query, variables: variables)
}
- // MARK: - Cached Execute
-
- /// Execute a query and cache the raw response data. Returns cached data
- /// immediately on cache hit, then refreshes in the background via the
- /// `onRefresh` callback.
- func executeCached<T: Decodable>(
- service: SRHTService,
- query: String,
- variables: [String: any Sendable]? = nil,
- responseType _: T.Type,
- cacheKey: String
- ) async throws -> T {
- // Try cache first
- if let cachedData = responseCache.get(forKey: cacheKey),
- let cached = try? decoder.decode(GraphQLResponse<T>.self, from: cachedData),
- let data = cached.data {
- return data
- }
-
- // No cache hit — fetch normally
- return try await executeAndCache(
- service: service,
- query: query,
- variables: variables,
- responseType: T.self,
- cacheKey: cacheKey
- )
- }
-
- /// Execute a query, cache the raw data, and return the decoded result.
- func executeAndCache<T: Decodable>(
- service: SRHTService,
- query: String,
- variables: [String: any Sendable]? = nil,
- responseType _: T.Type,
- cacheKey: String
- ) async throws -> T {
- guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
- throw SRHTError.unauthorized
- }
-
- var request = URLRequest(url: service.url)
- request.httpMethod = "POST"
- request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
- request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-
- let body = GraphQLRequestBody(
- query: query,
- variables: variables?.mapValues { AnyCodable($0) }
- )
- request.httpBody = try encoder.encode(body)
-
- let (data, response): (Data, URLResponse)
- do {
- (data, response) = try await session.data(for: request)
- } catch {
- throw SRHTError.networkError(error)
- }
-
- if let http = response as? HTTPURLResponse {
- if http.statusCode == 401 {
- throw SRHTError.unauthorized
- }
- if !(200...299).contains(http.statusCode) {
- try throwGraphQLErrorsIfPresent(in: data)
- throw SRHTError.httpError(http.statusCode)
- }
- }
-
- // Cache the raw response data before decoding
- responseCache.set(data, forKey: cacheKey)
-
- let graphQLResponse: GraphQLResponse<T>
- do {
- graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
- } catch {
- #if DEBUG
- let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
- let variablesDescription = String(describing: variables)
- if let decodingError = error as? DecodingError {
- logger.error(
- """
- Decoding failed for \(String(describing: T.self), privacy: .public)
- service: \(service.rawValue, privacy: .public)
- query:
- \(query, privacy: .public)
- variables:
- \(variablesDescription, privacy: .public)
- decodingError:
- \(String(describing: decodingError), privacy: .public)
- response:
- \(responseBody, privacy: .public)
- """
- )
- } else {
- logger.error(
- """
- Decoding failed for \(String(describing: T.self), privacy: .public)
- service: \(service.rawValue, privacy: .public)
- query:
- \(query, privacy: .public)
- variables:
- \(variablesDescription, privacy: .public)
- error:
- \(String(describing: error), privacy: .public)
- response:
- \(responseBody, privacy: .public)
- """
- )
- }
- #else
- logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
- #endif
- throw SRHTError.decodingError(error)
- }
-
- if let errors = graphQLResponse.errors, !errors.isEmpty {
- throw SRHTError.graphQLErrors(errors)
- }
-
- guard let result = graphQLResponse.data else {
- throw SRHTError.decodingError(
- DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
- )
- }
-
- return result
- }
// MARK: - Plain-text fetch
@@ -748,11 +362,9 @@ final class SRHTClient: Sendable {
// MARK: - Data Helper
private extension SRHTClient {
- func performGraphQLRequest(
- service: SRHTService,
- query: String,
- variables: [String: any Sendable]?
- ) async throws -> Data {
+ /// Builds an authorized POST for `service`. Throws ``SRHTError/unauthorized``
+ /// when no token is set, so callers never have to guard separately.
+ func makeAuthorizedRequest(service: SRHTService, contentType: String) throws -> URLRequest {
guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
throw SRHTError.unauthorized
}
@@ -761,14 +373,14 @@ private extension SRHTClient {
request.httpMethod = "POST"
request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-
- let body = GraphQLRequestBody(
- query: query,
- variables: variables?.mapValues { AnyCodable($0) }
- )
- request.httpBody = try encoder.encode(body)
+ request.setValue(contentType, forHTTPHeaderField: "Content-Type")
+ return request
+ }
+ /// Sends a prepared request and returns the raw body, mapping transport and
+ /// HTTP failures onto ``SRHTError``. sr.ht reports GraphQL errors under a 200
+ /// as often as under a 4xx, so both paths check the envelope.
+ func send(_ request: URLRequest) async throws -> Data {
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
@@ -790,6 +402,25 @@ private extension SRHTClient {
return data
}
+ func encodedGraphQLBody(query: String, variables: [String: any Sendable]?) throws -> Data {
+ try encoder.encode(
+ GraphQLRequestBody(
+ query: query,
+ variables: variables?.mapValues { AnyCodable($0) }
+ )
+ )
+ }
+
+ func performGraphQLRequest(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]?
+ ) async throws -> Data {
+ var request = try makeAuthorizedRequest(service: service, contentType: "application/json")
+ request.httpBody = try encodedGraphQLBody(query: query, variables: variables)
+ return try await send(request)
+ }
+
func decodeGraphQLData<T: Decodable>(
_ data: Data,
service: SRHTService,
diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift
index 2fcc640..09fa2a0 100644
--- a/Hutch/Views/Lists/MailingListListView.swift
+++ b/Hutch/Views/Lists/MailingListListView.swift
@@ -5,6 +5,7 @@ import SwiftUI
final class MailingListListViewModel {
private(set) var mailingLists: [InboxMailingListReference] = []
private(set) var isLoading = false
+ private(set) var isPerformingAction = false
var error: String?
var searchText = ""
@@ -28,10 +29,51 @@ final class MailingListListViewModel {
}
"""
+ private static let unsubscribeMutation = """
+ mutation mailingListUnsubscribe($listID: Int!) {
+ subscription: mailingListUnsubscribe(listID: $listID) { id }
+ }
+ """
+
init(client: SRHTClient) {
self.client = client
}
+ /// Unsubscribes from a list and drops it from the list on success. This view
+ /// is built from the subscriptions query, so a successful unsubscribe means
+ /// the row no longer belongs here.
+ func unsubscribe(from mailingList: InboxMailingListReference) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ let previousLists = mailingLists
+ mailingLists.removeAll { $0.rid == mailingList.rid }
+
+ do {
+ struct Response: Decodable, Sendable {
+ // mailingListUnsubscribe is nullable: sr.ht returns null when there
+ // was no subscription to remove, which is still a success.
+ let subscription: SubscriptionPayload?
+ }
+
+ struct SubscriptionPayload: Decodable, Sendable {
+ let id: Int
+ }
+
+ _ = try await client.execute(
+ service: .lists,
+ query: Self.unsubscribeMutation,
+ variables: ["listID": mailingList.id],
+ responseType: Response.self
+ )
+ } catch {
+ mailingLists = previousLists
+ self.error = "Couldn't unsubscribe from \(mailingList.name). \(error.userFacingMessage)"
+ }
+ }
+
var filteredMailingLists: [InboxMailingListReference] {
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !q.isEmpty else { return mailingLists }
@@ -106,6 +148,7 @@ final class MailingListListViewModel {
struct MailingListListView: View {
@Environment(AppState.self) private var appState
@State private var viewModel: MailingListListViewModel?
+ @State private var pendingUnsubscribe: InboxMailingListReference?
var body: some View {
Group {
@@ -141,6 +184,14 @@ struct MailingListListView: View {
}
.padding(.vertical, 2)
}
+ .swipeActions(edge: .trailing) {
+ Button {
+ pendingUnsubscribe = mailingList
+ } label: {
+ SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
+ }
+ .tint(.orange)
+ }
}
.themedRow()
}
@@ -151,6 +202,22 @@ struct MailingListListView: View {
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search lists"
)
+ .confirmationDialog(
+ pendingUnsubscribe.map { "Unsubscribe from \($0.name)?" } ?? "",
+ isPresented: .init(
+ get: { pendingUnsubscribe != nil },
+ set: { if !$0 { pendingUnsubscribe = nil } }
+ ),
+ titleVisibility: .visible,
+ presenting: pendingUnsubscribe
+ ) { mailingList in
+ Button("Unsubscribe", role: .destructive) {
+ Task { await viewModel.unsubscribe(from: mailingList) }
+ }
+ Button("Cancel", role: .cancel) { pendingUnsubscribe = nil }
+ } message: { _ in
+ 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.")
+ }
.overlay {
if viewModel.isLoading, viewModel.mailingLists.isEmpty {
SRHTLoadingStateView(message: "Loading mailing lists…")
diff --git a/Hutch/Views/Settings/NotificationPreferencesViewModel.swift b/Hutch/Views/Settings/NotificationPreferencesViewModel.swift
new file mode 100644
index 0000000..dc60eae
--- /dev/null
+++ b/Hutch/Views/Settings/NotificationPreferencesViewModel.swift
@@ -0,0 +1,173 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TodoPreferencesResponse: Decodable, Sendable {
+ let preferences: TodoPreferences
+}
+
+private struct TodoPreferences: Decodable, Sendable {
+ let notifySelf: Bool
+}
+
+private struct ListsPreferencesResponse: Decodable, Sendable {
+ let preferences: ListsPreferences
+}
+
+private struct ListsPreferences: Decodable, Sendable {
+ let copySelf: Bool
+}
+
+// MARK: - View Model
+
+/// Email preferences for todo.sr.ht and lists.sr.ht.
+///
+/// The two services each expose `preferences`/`updatePreferences` under the same
+/// names but with different fields — `notifySelf` on todo, `copySelf` on lists —
+/// and there is no shared preferences service, so both are handled side by side.
+@Observable
+@MainActor
+final class NotificationPreferencesViewModel {
+
+ private(set) var notifySelf = false
+ private(set) var copySelf = false
+ private(set) var isLoading = false
+ private(set) var isSavingNotifySelf = false
+ private(set) var isSavingCopySelf = false
+ private(set) var hasLoaded = false
+ var error: String?
+
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ private static let todoPreferencesQuery = """
+ query todoPreferences {
+ preferences { notifySelf }
+ }
+ """
+
+ private static let listsPreferencesQuery = """
+ query listsPreferences {
+ preferences { copySelf }
+ }
+ """
+
+ private static let updateNotifySelfMutation = """
+ mutation updateTodoPreferences($notifySelf: Boolean!) {
+ preferences: updatePreferences(preferences: { notifySelf: $notifySelf }) {
+ notifySelf
+ }
+ }
+ """
+
+ private static let updateCopySelfMutation = """
+ mutation updateListsPreferences($copySelf: Boolean!) {
+ preferences: updatePreferences(preferences: { copySelf: $copySelf }) {
+ copySelf
+ }
+ }
+ """
+
+ func loadIfNeeded() async {
+ guard !hasLoaded, !isLoading else { return }
+ await load()
+ }
+
+ func load() async {
+ isLoading = true
+ error = nil
+ defer {
+ isLoading = false
+ hasLoaded = true
+ }
+
+ // The two services are independent; one being unreachable should not hide
+ // the other's setting.
+ async let todo = fetchNotifySelf()
+ async let lists = fetchCopySelf()
+
+ let (todoResult, listsResult) = await (todo, lists)
+
+ if let todoResult {
+ notifySelf = todoResult
+ }
+ if let listsResult {
+ copySelf = listsResult
+ }
+
+ if todoResult == nil && listsResult == nil {
+ error = "Couldn't load your email preferences."
+ }
+ }
+
+ /// The fetches stay in their own methods so the response types are only ever
+ /// decoded on the main actor. The module defaults to MainActor isolation, so
+ /// decoding straight from an `async let` would use a main-actor-isolated
+ /// Decodable conformance from a nonisolated context.
+ private func fetchNotifySelf() async -> Bool? {
+ let response = try? await client.execute(
+ service: .todo,
+ query: Self.todoPreferencesQuery,
+ responseType: TodoPreferencesResponse.self
+ )
+ return response?.preferences.notifySelf
+ }
+
+ private func fetchCopySelf() async -> Bool? {
+ let response = try? await client.execute(
+ service: .lists,
+ query: Self.listsPreferencesQuery,
+ responseType: ListsPreferencesResponse.self
+ )
+ return response?.preferences.copySelf
+ }
+
+ func setNotifySelf(_ newValue: Bool) async {
+ guard !isSavingNotifySelf else { return }
+ isSavingNotifySelf = true
+ error = nil
+ defer { isSavingNotifySelf = false }
+
+ let previous = notifySelf
+ notifySelf = newValue
+
+ do {
+ let response = try await client.execute(
+ service: .todo,
+ query: Self.updateNotifySelfMutation,
+ variables: ["notifySelf": newValue],
+ responseType: TodoPreferencesResponse.self
+ )
+ notifySelf = response.preferences.notifySelf
+ } catch {
+ notifySelf = previous
+ self.error = "Couldn't update ticket email preference. \(error.userFacingMessage)"
+ }
+ }
+
+ func setCopySelf(_ newValue: Bool) async {
+ guard !isSavingCopySelf else { return }
+ isSavingCopySelf = true
+ error = nil
+ defer { isSavingCopySelf = false }
+
+ let previous = copySelf
+ copySelf = newValue
+
+ do {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.updateCopySelfMutation,
+ variables: ["copySelf": newValue],
+ responseType: ListsPreferencesResponse.self
+ )
+ copySelf = response.preferences.copySelf
+ } catch {
+ copySelf = previous
+ self.error = "Couldn't update mailing list email preference. \(error.userFacingMessage)"
+ }
+ }
+}
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 0dd4319..8d6bd87 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -10,16 +10,23 @@ struct SettingsView: View {
private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
@State private var pendingDestructiveAction: SettingsDestructiveAction?
@State private var showAccountSwitcher = false
+ @State private var preferences: NotificationPreferencesViewModel?
var body: some View {
Form {
appearanceSection()
behaviorSection()
+ emailSection()
safariExtensionSection()
authenticationSection()
}
.themedList()
.navigationTitle("Settings")
+ .task {
+ let viewModel = preferences ?? NotificationPreferencesViewModel(client: appState.client)
+ preferences = viewModel
+ await viewModel.loadIfNeeded()
+ }
.sheet(isPresented: $showAccountSwitcher) {
AccountSwitcherView()
}
@@ -118,6 +125,51 @@ struct SettingsView: View {
}
@ViewBuilder
+ private func emailSection() -> some View {
+ Section {
+ if let preferences {
+ Toggle(
+ "Notify me about my own tickets",
+ isOn: Binding(
+ get: { preferences.notifySelf },
+ set: { newValue in
+ Task { await preferences.setNotifySelf(newValue) }
+ }
+ )
+ )
+ .disabled(preferences.isLoading || preferences.isSavingNotifySelf)
+ .themedRow()
+
+ Toggle(
+ "Copy me on my own list mail",
+ isOn: Binding(
+ get: { preferences.copySelf },
+ set: { newValue in
+ Task { await preferences.setCopySelf(newValue) }
+ }
+ )
+ )
+ .disabled(preferences.isLoading || preferences.isSavingCopySelf)
+ .themedRow()
+
+ if let error = preferences.error {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .themedRow()
+ }
+ } else {
+ ProgressView()
+ .themedRow()
+ }
+ } header: {
+ Text("Email")
+ } footer: {
+ 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.")
+ }
+ }
+
+ @ViewBuilder
private func authenticationSection() -> some View {
Section {
HStack {
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index 92263f6..b114fa6 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -11,12 +11,15 @@ struct TicketDetailView: View {
@Environment(AppState.self) private var appState
@Environment(\.colorScheme) private var colorScheme
@Environment(\.openURL) private var openURL
+ @Environment(\.dismiss) private var dismiss
@State private var viewModel: TicketDetailViewModel?
// Sheet state
@State private var showResolveSheet = false
@State private var showAssignSheet = false
@State private var showLabelsSheet = false
+ @State private var showEditSheet = false
+ @State private var showDeleteConfirmation = false
@State private var isOpeningTracker = false
// Comment composer mode
@@ -100,9 +103,30 @@ struct TicketDetailView: View {
SwiftUI.Label("Copy Tracker RID", systemImage: "number")
}
+ Divider()
+
+ Button {
+ Task { await viewModel.toggleSubscription() }
+ } label: {
+ if viewModel.isSubscribed {
+ SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
+ } else {
+ SwiftUI.Label("Subscribe", systemImage: "bell")
+ }
+ }
+ .disabled(viewModel.isPerformingAction)
+
if isOwnedByCurrentUser {
Divider()
+ if viewModel.ticket != nil {
+ Button {
+ showEditSheet = true
+ } label: {
+ SwiftUI.Label("Edit Ticket", systemImage: "square.and.pencil")
+ }
+ }
+
if let ticket = viewModel.ticket {
if ticket.status == .resolved {
Button {
@@ -133,6 +157,14 @@ struct TicketDetailView: View {
} label: {
SwiftUI.Label("Manage Labels", systemImage: "tag")
}
+
+ Divider()
+
+ Button(role: .destructive) {
+ showDeleteConfirmation = true
+ } label: {
+ SwiftUI.Label("Delete Ticket", systemImage: "trash")
+ }
}
} label: {
Image(systemName: "ellipsis.circle")
@@ -150,6 +182,32 @@ struct TicketDetailView: View {
LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
.presentationDetents([.medium])
}
+ .sheet(isPresented: $showEditSheet) {
+ if let ticket = viewModel.ticket {
+ EditTicketSheet(
+ viewModel: viewModel,
+ isPresented: $showEditSheet,
+ initialSubject: ticket.title,
+ initialBody: ticket.description ?? ""
+ )
+ }
+ }
+ .confirmationDialog(
+ "Delete Ticket #\(ticketId)?",
+ isPresented: $showDeleteConfirmation,
+ titleVisibility: .visible
+ ) {
+ Button("Delete Ticket", role: .destructive) {
+ Task {
+ if await viewModel.deleteTicket() {
+ dismiss()
+ }
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("This permanently deletes the ticket and its comments. This cannot be undone.")
+ }
}
// MARK: - Detail Content
@@ -724,6 +782,81 @@ private struct EventRow: View {
// MARK: - Resolve Sheet
+private struct EditTicketSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ let initialSubject: String
+ let initialBody: String
+
+ @State private var subject: String
+ @State private var ticketBody: String
+
+ init(
+ viewModel: TicketDetailViewModel,
+ isPresented: Binding<Bool>,
+ initialSubject: String,
+ initialBody: String
+ ) {
+ self.viewModel = viewModel
+ _isPresented = isPresented
+ self.initialSubject = initialSubject
+ self.initialBody = initialBody
+ _subject = State(initialValue: initialSubject)
+ _ticketBody = State(initialValue: initialBody)
+ }
+
+ private var trimmedSubject: String {
+ subject.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private var hasChanges: Bool {
+ trimmedSubject != initialSubject
+ || ticketBody.trimmingCharacters(in: .whitespacesAndNewlines) != initialBody
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Subject") {
+ TextField("Subject", text: $subject, axis: .vertical)
+ .themedRow()
+ }
+
+ Section("Description") {
+ TextField("Description", text: $ticketBody, axis: .vertical)
+ .lineLimit(5...15)
+ .themedRow()
+ }
+ }
+ .themedList()
+ .navigationTitle("Edit Ticket")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { isPresented = false }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ Task {
+ if await viewModel.updateTicket(subject: subject, body: ticketBody) {
+ isPresented = false
+ }
+ }
+ }
+ .disabled(viewModel.isPerformingAction || trimmedSubject.isEmpty || !hasChanges)
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Resolve Sheet
+
private struct ResolveSheet: View {
let viewModel: TicketDetailViewModel
@Binding var isPresented: Bool
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
index df2fd46..714f03a 100644
--- a/Hutch/Views/Tickets/TicketDetailViewModel.swift
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -19,12 +19,18 @@ private struct TicketDetailPayload: Decodable, Sendable {
let status: TicketStatus
let resolution: TicketResolution?
let authenticity: Authenticity
+ /// Null when the authenticated user is not subscribed to this ticket.
+ let subscription: SubscriptionIdPayload?
let submitter: Entity
let assignees: [Entity]
let labels: [TicketLabel]
let events: EventsPage
}
+struct SubscriptionIdPayload: Decodable, Sendable {
+ let id: Int
+}
+
private struct EventsPage: Decodable, Sendable {
let results: [TicketEvent]
let cursor: String?
@@ -52,6 +58,22 @@ private struct UpdatedStatusEvent: Decodable, Sendable {
let eventType: String
}
+private struct TicketSubscriptionResponse: Decodable, Sendable {
+ let subscription: SubscriptionIdPayload
+}
+
+private struct UpdateTicketResponse: Decodable, Sendable {
+ let updateTicket: TicketIdPayload
+}
+
+private struct DeleteTicketResponse: Decodable, Sendable {
+ let deleteTicket: TicketIdPayload
+}
+
+private struct TicketIdPayload: Decodable, Sendable {
+ let id: Int
+}
+
private struct AssignUserResponse: Decodable, Sendable {
let assignUser: MutationEventResponse
}
@@ -112,6 +134,9 @@ final class TicketDetailViewModel {
private(set) var isLoading = false
private(set) var isSubmitting = false
private(set) var isPerformingAction = false
+ /// Whether the authenticated user receives email for this ticket. Mirrors
+ /// `Ticket.subscription`, which is null when not subscribed.
+ private(set) var isSubscribed = false
private(set) var trackerLabels: [TicketLabel] = []
private(set) var rawTicketResponse: String?
private(set) var cacheMetadata: CacheEntryMetadata?
@@ -141,6 +166,35 @@ final class TicketDetailViewModel {
return input
}
+ /// Builds an `UpdateTicketInput` carrying only the fields that changed, so an
+ /// edit never overwrites a field the user did not touch.
+ static func ticketUpdateInput(
+ subject: String,
+ body: String,
+ currentSubject: String,
+ currentBody: String?
+ ) -> [String: any Sendable] {
+ var input: [String: any Sendable] = [:]
+ let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if trimmedSubject != currentSubject {
+ input["subject"] = trimmedSubject
+ }
+
+ if trimmedBody != (currentBody ?? "") {
+ if trimmedBody.isEmpty {
+ // A nil subscript assignment would drop the key and leave the old
+ // body in place instead of clearing it.
+ input.updateValue(Optional<String>.none as any Sendable, forKey: "body")
+ } else {
+ input["body"] = trimmedBody
+ }
+ }
+
+ return input
+ }
+
init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
self.ownerUsername = ownerUsername
self.trackerName = trackerName
@@ -164,6 +218,7 @@ final class TicketDetailViewModel {
status
resolution
authenticity
+ subscription { id }
submitter { canonicalName }
assignees { canonicalName }
labels { id name backgroundColor foregroundColor }
@@ -233,6 +288,30 @@ final class TicketDetailViewModel {
}
"""
+ private static let updateTicketMutation = """
+ mutation updateTicket($trackerId: Int!, $ticketId: Int!, $input: UpdateTicketInput!) {
+ updateTicket(trackerId: $trackerId, ticketId: $ticketId, input: $input) { id }
+ }
+ """
+
+ private static let deleteTicketMutation = """
+ mutation deleteTicket($trackerId: Int!, $ticketId: Int!) {
+ deleteTicket(trackerId: $trackerId, ticketId: $ticketId) { id }
+ }
+ """
+
+ private static let ticketSubscribeMutation = """
+ mutation ticketSubscribe($trackerId: Int!, $ticketId: Int!) {
+ subscription: ticketSubscribe(trackerId: $trackerId, ticketId: $ticketId) { id }
+ }
+ """
+
+ private static let ticketUnsubscribeMutation = """
+ mutation ticketUnsubscribe($trackerId: Int!, $ticketId: Int!) {
+ subscription: ticketUnsubscribe(trackerId: $trackerId, ticketId: $ticketId) { id }
+ }
+ """
+
private static let assignUserMutation = """
mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
@@ -420,6 +499,99 @@ final class TicketDetailViewModel {
isPerformingAction = false
}
+ /// Edits the ticket's subject and body. Returns true when the edit was sent,
+ /// including the no-op case where nothing changed.
+ @discardableResult
+ func updateTicket(subject: String, body: String) async -> Bool {
+ guard !isPerformingAction, let ticket else { return false }
+
+ let input = Self.ticketUpdateInput(
+ subject: subject,
+ body: body,
+ currentSubject: ticket.title,
+ currentBody: ticket.description
+ )
+ guard !input.isEmpty else { return true }
+
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.updateTicketMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "input": input
+ ],
+ responseType: UpdateTicketResponse.self
+ )
+ await invalidateAfterMutation()
+ await reloadTicketPreservingDebugState()
+ return true
+ } catch {
+ self.error = error.userFacingMessage
+ return false
+ }
+ }
+
+ /// Subscribes to or unsubscribes from email notifications for this ticket.
+ func toggleSubscription() async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ let wasSubscribed = isSubscribed
+ // Reflect the change immediately; the catch below puts it back if the
+ // mutation fails, so the control never lies about server state.
+ isSubscribed.toggle()
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: wasSubscribed ? Self.ticketUnsubscribeMutation : Self.ticketSubscribeMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId
+ ],
+ responseType: TicketSubscriptionResponse.self
+ )
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
+ } catch {
+ isSubscribed = wasSubscribed
+ self.error = error.userFacingMessage
+ }
+ }
+
+ /// Deletes the ticket. Returns true on success so the caller can pop the view.
+ @discardableResult
+ func deleteTicket() async -> Bool {
+ guard !isPerformingAction else { return false }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.deleteTicketMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId
+ ],
+ responseType: DeleteTicketResponse.self
+ )
+ await invalidateAfterMutation()
+ return true
+ } catch {
+ self.error = error.userFacingMessage
+ return false
+ }
+ }
+
func assignUser(username: String) async {
guard !isPerformingAction else { return }
isPerformingAction = true
@@ -691,6 +863,7 @@ final class TicketDetailViewModel {
labels: payload.labels
)
ticket = updatedTicket
+ isSubscribed = payload.subscription != nil
let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder)
events = updatedEvents
}
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index afee96d..650554c 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -242,6 +242,7 @@ struct TicketListView: View {
trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
await vm.loadTickets()
await vm.loadTrackerLabels()
+ await vm.loadSubscriptionState()
}
}
}
@@ -469,6 +470,20 @@ struct TicketListView: View {
Divider()
+ if let viewModel {
+ Button {
+ Task { await viewModel.toggleSubscription() }
+ } label: {
+ Label(
+ viewModel.isSubscribed ? "Unsubscribe" : "Subscribe",
+ systemImage: viewModel.isSubscribed ? "bell.slash" : "bell"
+ )
+ }
+ .disabled(viewModel.isPerformingAction)
+ }
+
+ Divider()
+
if let trackerURL = SRHTWebURL.tracker(tracker) {
Button {
openURL(trackerURL)
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index 1f44993..2727dcc 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -33,6 +33,23 @@ private struct LabelMutationResponse: Decodable, Sendable {
let unlabelTicket: EventRef?
}
+private struct TrackerSubscriptionStateResponse: Decodable, Sendable {
+ let tracker: TrackerSubscriptionWrapper
+}
+
+private struct TrackerSubscriptionWrapper: Decodable, Sendable {
+ /// Null when the authenticated user is not subscribed to this tracker.
+ let subscription: TrackerSubscriptionIdPayload?
+}
+
+private struct TrackerSubscriptionResponse: Decodable, Sendable {
+ let subscription: TrackerSubscriptionIdPayload
+}
+
+private struct TrackerSubscriptionIdPayload: Decodable, Sendable {
+ let id: Int
+}
+
private struct TrackerLabelsResponse: Decodable, Sendable {
let tracker: TrackerLabelsWrapper
}
@@ -90,6 +107,9 @@ final class TicketListViewModel {
private(set) var isLoadingMore = false
private(set) var isCreatingTicket = false
private(set) var isPerformingAction = false
+ /// Whether the authenticated user receives email for this tracker. Mirrors
+ /// `Tracker.subscription`, which is null when not subscribed.
+ private(set) var isSubscribed = false
private(set) var trackerLabels: [TicketLabel] = []
private(set) var recentSearches: [ScopedSearchHistoryEntry]
private(set) var savedFilters: [SavedTicketFilter]
@@ -216,6 +236,28 @@ final class TicketListViewModel {
}
"""
+ /// Kept separate from `query` above, which is paginated and cached — the
+ /// subscription is per-user state and should not ride along in page payloads.
+ private static let trackerSubscriptionQuery = """
+ query trackerSubscription($rid: ID!) {
+ tracker(rid: $rid) {
+ subscription { id }
+ }
+ }
+ """
+
+ private static let trackerSubscribeMutation = """
+ mutation trackerSubscribe($trackerId: Int!) {
+ subscription: trackerSubscribe(trackerId: $trackerId) { id }
+ }
+ """
+
+ private static let trackerUnsubscribeMutation = """
+ mutation trackerUnsubscribe($trackerId: Int!, $tickets: Boolean!) {
+ subscription: trackerUnsubscribe(trackerId: $trackerId, tickets: $tickets) { id }
+ }
+ """
+
private static let trackerLabelsQuery = """
query trackerLabels($rid: ID!) {
tracker(rid: $rid) {
@@ -520,6 +562,52 @@ final class TicketListViewModel {
isPerformingAction = false
}
+ /// Reads whether the user is subscribed to this tracker. Uncached: it is
+ /// per-user state that must be accurate the moment the menu opens.
+ func loadSubscriptionState() async {
+ do {
+ let response = try await client.execute(
+ service: .todo,
+ query: Self.trackerSubscriptionQuery,
+ variables: ["rid": trackerRid],
+ responseType: TrackerSubscriptionStateResponse.self
+ )
+ isSubscribed = response.tracker.subscription != nil
+ } catch {
+ // Leave the last known value alone; the toggle reports its own errors.
+ }
+ }
+
+ /// Subscribes to or unsubscribes from email notifications for this tracker.
+ /// Unsubscribing leaves individual ticket subscriptions intact.
+ func toggleSubscription() async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ let wasSubscribed = isSubscribed
+ isSubscribed.toggle()
+
+ var variables: [String: any Sendable] = ["trackerId": trackerId]
+ if wasSubscribed {
+ variables["tickets"] = false
+ }
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: wasSubscribed ? Self.trackerUnsubscribeMutation : Self.trackerSubscribeMutation,
+ variables: variables,
+ responseType: TrackerSubscriptionResponse.self
+ )
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
+ } catch {
+ isSubscribed = wasSubscribed
+ self.error = error.userFacingMessage
+ }
+ }
+
func loadTrackerLabels() async {
do {
let cached = try await client.executeCached(