aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj24
-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
-rw-r--r--HutchTests/TicketDetailViewModelTests.swift69
-rw-r--r--ROADMAP.md66
11 files changed, 862 insertions, 453 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 2986b7c..4d1898a 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -597,7 +597,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 87;
+ CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.5.0;
+ MARKETING_VERSION = 3.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 87;
+ CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.5.0;
+ MARKETING_VERSION = 3.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 87;
+ CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.5.0;
+ MARKETING_VERSION = 3.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 87;
+ CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.5.0;
+ MARKETING_VERSION = 3.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 87;
+ CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.5.0;
+ MARKETING_VERSION = 3.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 87;
+ CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.5.0;
+ MARKETING_VERSION = 3.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
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(
diff --git a/HutchTests/TicketDetailViewModelTests.swift b/HutchTests/TicketDetailViewModelTests.swift
index bf0bc60..ba4a476 100644
--- a/HutchTests/TicketDetailViewModelTests.swift
+++ b/HutchTests/TicketDetailViewModelTests.swift
@@ -27,4 +27,73 @@ struct TicketDetailViewModelTests {
#expect(input["status"] as? String == TicketStatus.resolved.rawValue)
#expect(input["resolution"] as? String == TicketResolution.fixed.rawValue)
}
+
+ @Test
+ @MainActor
+ func ticketUpdateInputOmitsUnchangedFields() {
+ let input = TicketDetailViewModel.ticketUpdateInput(
+ subject: "Same subject",
+ body: "Same body",
+ currentSubject: "Same subject",
+ currentBody: "Same body"
+ )
+
+ #expect(input.isEmpty)
+ }
+
+ @Test
+ @MainActor
+ func ticketUpdateInputCarriesOnlyTheChangedField() {
+ let input = TicketDetailViewModel.ticketUpdateInput(
+ subject: "New subject",
+ body: "Same body",
+ currentSubject: "Old subject",
+ currentBody: "Same body"
+ )
+
+ #expect(input["subject"] as? String == "New subject")
+ #expect(!input.keys.contains("body"))
+ }
+
+ @Test
+ @MainActor
+ func ticketUpdateInputTrimsWhitespaceBeforeComparing() {
+ let input = TicketDetailViewModel.ticketUpdateInput(
+ subject: " Same subject ",
+ body: "\n Same body \n",
+ currentSubject: "Same subject",
+ currentBody: "Same body"
+ )
+
+ #expect(input.isEmpty)
+ }
+
+ @Test
+ @MainActor
+ func ticketUpdateInputUsesNilToClearBody() {
+ let input = TicketDetailViewModel.ticketUpdateInput(
+ subject: "Same subject",
+ body: " ",
+ currentSubject: "Same subject",
+ currentBody: "Existing body"
+ )
+
+ // The key must survive with a nil value so it encodes as a JSON null and
+ // actually clears the body, rather than being dropped from the mutation.
+ #expect(input.keys.contains("body"))
+ #expect(input["body"] as? String == nil)
+ }
+
+ @Test
+ @MainActor
+ func ticketUpdateInputTreatsNilBodyAsEmpty() {
+ let input = TicketDetailViewModel.ticketUpdateInput(
+ subject: "Same subject",
+ body: "",
+ currentSubject: "Same subject",
+ currentBody: nil
+ )
+
+ #expect(input.isEmpty)
+ }
}
diff --git a/ROADMAP.md b/ROADMAP.md
index 875b949..e86ddb0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -36,35 +36,43 @@ been running only on demand in Xcode, and ten had rotted:
and inbox threads keyed `id` on a subject-derived grouping key so two threads
sharing a subject on one list collided under `Identifiable`.
-## Phase 1: Close the write gaps
-
-Small, independently shippable mutations that already exist in the API but are
-never called. Each removes a "why can't I do this here?" moment.
-
-- `updateTicket` — edit ticket title and description after creation. Currently
- a ticket can be created and its status changed, but never edited.
-- `deleteTicket` — delete a ticket.
-- `trackerSubscribe` / `trackerUnsubscribe`, `ticketSubscribe` /
- `ticketUnsubscribe`, `mailingListSubscribe` / `mailingListUnsubscribe` —
- subscriptions are currently read-only. `MailingListListView` reads the
- `subscriptions` query, but nothing can subscribe or unsubscribe.
-- `updatePreferences` (todo.sr.ht and lists.sr.ht) — email notification
- preferences.
-
-### Refactors to fold in
-
-These are touched by everything in later phases, so they belong here rather
-than as standalone work.
-
-- `SRHTClient` has four near-identical request-and-decode paths (`execute`,
- `executeMultipart`, `executeMultipartFiles`, `executeAndCache`, plus the
- private `performGraphQLRequest`). The token guard, header setup, status-code
- handling, and a ~35-line `#if DEBUG` logging block are each duplicated about
- five times. Collapse to one request builder and one decode helper.
-- Two caches overlap: the in-memory `responseCache` and the persistent `cache`,
- reached through two different `executeCached` overloads with different return
- types and semantics (one does stale-while-revalidate with TTLs, the other only
- checks memory). Unify on the TTL-aware path.
+## Phase 1: Close the write gaps — done (v3.6.0)
+
+Small, independently shippable mutations that already existed in the API but
+were never called. Each removes a "why can't I do this here?" moment.
+
+- ~~`updateTicket`~~ — edit a ticket's subject and body after creation.
+- ~~`deleteTicket`~~ — delete a ticket, behind a confirmation.
+- ~~`ticketSubscribe` / `ticketUnsubscribe`, `trackerSubscribe` /
+ `trackerUnsubscribe`~~ — `Ticket.subscription` and `Tracker.subscription` are
+ null when not subscribed, so both toggles reflect real server state.
+- ~~`mailingListUnsubscribe`~~ — see the caveat below.
+- ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and
+ `copySelf`, surfaced as an Email section in Settings.
+
+`mailingListSubscribe` is deliberately not wired up. `MailingList` has no
+`subscription` field, unlike `Ticket` and `Tracker`, so per-list state is only
+knowable from the `subscriptions` query — which by definition lists what the
+user is already subscribed to. Subscribing needs a list the user is *not*
+subscribed to, and sr.ht exposes no discovery API to find one (see
+[SCOPE.md](SCOPE.md) on hub.sr.ht). Revisit if hub.sr.ht ever gains an API, or
+alongside Phase 2, which surfaces lists through patchsets.
+
+### Refactors folded in
+
+- ~~Collapse `SRHTClient`'s duplicated request paths~~. Extracted
+ `makeAuthorizedRequest`, `send`, and `encodedGraphQLBody`; `executeMultipart`
+ became the single-file case of `executeMultipartFiles`. The `#if DEBUG`
+ logging block went from five copies to one. 938 lines to 569.
+- ~~Unify the two `executeCached` overloads~~. The memory-only overload and
+ `executeAndCache` turned out to be dead — all 38 call sites already used the
+ TTL-aware path — so both were removed rather than merged. `responseCache`
+ remains as the in-memory layer behind `cachedPayload`.
+
+Known follow-up: `BuildListViewModel`, `RepositoryListViewModel`, and
+`PasteService` still read `client.responseCache` directly, falling back across
+two different cache keys. That predates `APICacheKeys` and should be folded into
+`cachedPayload`.
## Phase 2: Patchsets