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