diff options
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 24 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTClient.swift | 455 | ||||
| -rw-r--r-- | Hutch/Views/Lists/MailingListListView.swift | 67 | ||||
| -rw-r--r-- | Hutch/Views/Settings/NotificationPreferencesViewModel.swift | 173 | ||||
| -rw-r--r-- | Hutch/Views/Settings/SettingsView.swift | 52 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 133 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailViewModel.swift | 173 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 15 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 88 | ||||
| -rw-r--r-- | HutchTests/TicketDetailViewModelTests.swift | 69 | ||||
| -rw-r--r-- | ROADMAP.md | 66 |
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) + } } @@ -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 |
