Commit 8f824f9873
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 = 95; | |
| 600 | CURRENT_PROJECT_VERSION = 96; | |
| 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.10.0; | |
| 617 | MARKETING_VERSION = 3.11.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 = 95; | |
| 637 | CURRENT_PROJECT_VERSION = 96; | |
| 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.10.0; | |
| 654 | MARKETING_VERSION = 3.11.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 = 95; | |
| 717 | CURRENT_PROJECT_VERSION = 96; | |
| 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.10.0; | |
| 727 | MARKETING_VERSION = 3.11.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 = 95; | |
| 746 | CURRENT_PROJECT_VERSION = 96; | |
| 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.10.0; | |
| 756 | MARKETING_VERSION = 3.11.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 = 95; | |
| 775 | CURRENT_PROJECT_VERSION = 96; | |
| 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.10.0; | |
| 785 | MARKETING_VERSION = 3.11.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 = 95; | |
| 804 | CURRENT_PROJECT_VERSION = 96; | |
| 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.10.0; | |
| 814 | MARKETING_VERSION = 3.11.0; | |
| 815 | 815 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; |
| 816 | 816 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 817 | 817 | SKIP_INSTALL = YES; |
Hutch/Views/Projects/ProjectMailingListView.swift +143
| @@ -37,6 +37,36 @@ private struct PatchsetSummaryPayload: Decodable, Sendable { | ||
| 37 | 37 | let status: PatchsetStatus |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | private struct ListMetaResponse: Decodable, Sendable { | |
| 41 | let list: ListMetaPayload? | |
| 42 | } | |
| 43 | ||
| 44 | private struct ListMetaPayload: Decodable, Sendable { | |
| 45 | let id: Int | |
| 46 | let owner: Entity | |
| 47 | } | |
| 48 | ||
| 49 | private struct SubscriptionRidsResponse: Decodable, Sendable { | |
| 50 | let subscriptions: SubscriptionRidsPage | |
| 51 | } | |
| 52 | ||
| 53 | private struct SubscriptionRidsPage: Decodable, Sendable { | |
| 54 | let results: [SubscriptionRidEntry] | |
| 55 | let cursor: String? | |
| 56 | } | |
| 57 | ||
| 58 | private struct SubscriptionRidEntry: Decodable, Sendable { | |
| 59 | let list: SubscriptionRidList? | |
| 60 | } | |
| 61 | ||
| 62 | private struct SubscriptionRidList: Decodable, Sendable { | |
| 63 | let rid: String | |
| 64 | } | |
| 65 | ||
| 66 | /// Toggle mutations return the subscription (nullable on unsubscribe); only | |
| 67 | /// success matters. | |
| 68 | private struct SubscriptionToggleResponse: Decodable, Sendable {} | |
| 69 | ||
| 40 | 70 | @Observable |
| 41 | 71 | @MainActor |
| 42 | 72 | final class MailingListDetailViewModel { |
| @@ -47,6 +77,13 @@ final class MailingListDetailViewModel { | ||
| 47 | 77 | var error: String? |
| 48 | 78 | var searchText = "" |
| 49 | 79 | |
| 80 | /// Subscription state. `isSubscribed` is `nil` while unknown or unavailable | |
| 81 | /// (the toggle stays hidden); `isOwnList` hides it for lists you own. | |
| 82 | private(set) var listNumericID: Int? | |
| 83 | private(set) var isSubscribed: Bool? | |
| 84 | private(set) var isOwnList = false | |
| 85 | private(set) var isTogglingSubscription = false | |
| 86 | ||
| 50 | 87 | private let mailingList: InboxMailingListReference |
| 51 | 88 | private let client: SRHTClient |
| 52 | 89 | private let defaults: UserDefaults |
| @@ -86,6 +123,96 @@ final class MailingListDetailViewModel { | ||
| 86 | 123 | self.accountID = accountID |
| 87 | 124 | } |
| 88 | 125 | |
| 126 | // MARK: - Subscription | |
| 127 | ||
| 128 | private static let listMetaQuery = """ | |
| 129 | query listMeta($rid: ID!) { | |
| 130 | list(rid: $rid) { id owner { canonicalName } } | |
| 131 | } | |
| 132 | """ | |
| 133 | ||
| 134 | // `MailingList.subscription` is unreliable (see the API-traps note), so | |
| 135 | // subscribe state comes from the authoritative `subscriptions` query. | |
| 136 | private static let subscriptionRidsQuery = """ | |
| 137 | query subscriptionRids($cursor: Cursor) { | |
| 138 | subscriptions(cursor: $cursor) { | |
| 139 | results { | |
| 140 | ... on MailingListSubscription { list { rid } } | |
| 141 | } | |
| 142 | cursor | |
| 143 | } | |
| 144 | } | |
| 145 | """ | |
| 146 | ||
| 147 | private static let subscribeMutation = """ | |
| 148 | mutation mailingListSubscribe($id: Int!) { | |
| 149 | mailingListSubscribe(listID: $id) { id } | |
| 150 | } | |
| 151 | """ | |
| 152 | ||
| 153 | private static let unsubscribeMutation = """ | |
| 154 | mutation mailingListUnsubscribe($id: Int!) { | |
| 155 | mailingListUnsubscribe(listID: $id) { id } | |
| 156 | } | |
| 157 | """ | |
| 158 | ||
| 159 | /// Resolves the list's numeric id, whether the viewer owns it, and — for | |
| 160 | /// lists they don't own — whether they're subscribed. | |
| 161 | func loadSubscriptionState(currentUserCanonicalName: String?) async { | |
| 162 | do { | |
| 163 | let meta = try await client.execute( | |
| 164 | service: .lists, | |
| 165 | query: Self.listMetaQuery, | |
| 166 | variables: ["rid": mailingList.rid], | |
| 167 | responseType: ListMetaResponse.self | |
| 168 | ) | |
| 169 | guard let list = meta.list else { return } | |
| 170 | listNumericID = list.id | |
| 171 | ||
| 172 | if let currentUserCanonicalName, list.owner.canonicalName == currentUserCanonicalName { | |
| 173 | isOwnList = true | |
| 174 | return | |
| 175 | } | |
| 176 | isSubscribed = try await isSubscribed(toRid: mailingList.rid) | |
| 177 | } catch { | |
| 178 | // Leave state unknown; the toggle stays hidden rather than lying. | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | private func isSubscribed(toRid rid: String) async throws -> Bool { | |
| 183 | var cursor: String? | |
| 184 | repeat { | |
| 185 | let response = try await client.execute( | |
| 186 | service: .lists, | |
| 187 | query: Self.subscriptionRidsQuery, | |
| 188 | variables: cursor.map { ["cursor": $0] }, | |
| 189 | responseType: SubscriptionRidsResponse.self | |
| 190 | ) | |
| 191 | if response.subscriptions.results.contains(where: { $0.list?.rid == rid }) { | |
| 192 | return true | |
| 193 | } | |
| 194 | cursor = response.subscriptions.cursor | |
| 195 | } while cursor != nil | |
| 196 | return false | |
| 197 | } | |
| 198 | ||
| 199 | func toggleSubscription() async { | |
| 200 | guard let id = listNumericID, let subscribed = isSubscribed, !isTogglingSubscription else { return } | |
| 201 | isTogglingSubscription = true | |
| 202 | defer { isTogglingSubscription = false } | |
| 203 | do { | |
| 204 | _ = try await client.execute( | |
| 205 | service: .lists, | |
| 206 | query: subscribed ? Self.unsubscribeMutation : Self.subscribeMutation, | |
| 207 | variables: ["id": id], | |
| 208 | responseType: SubscriptionToggleResponse.self | |
| 209 | ) | |
| 210 | isSubscribed = !subscribed | |
| 211 | } catch { | |
| 212 | self.error = error.userFacingMessage | |
| 213 | } | |
| 214 | } | |
| 215 | ||
| 89 | 216 | var filteredThreads: [InboxThreadSummary] { |
| 90 | 217 | Self.filterThreads(threads, matching: searchText) |
| 91 | 218 | } |
| @@ -386,6 +513,21 @@ struct MailingListDetailView: View { | ||
| 386 | 513 | .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") |
| 387 | 514 | } |
| 388 | 515 | } |
| 516 | if let viewModel, !viewModel.isOwnList, let subscribed = viewModel.isSubscribed { | |
| 517 | ToolbarItem(placement: .topBarTrailing) { | |
| 518 | Button { | |
| 519 | Task { await viewModel.toggleSubscription() } | |
| 520 | } label: { | |
| 521 | if viewModel.isTogglingSubscription { | |
| 522 | ProgressView().controlSize(.small) | |
| 523 | } else { | |
| 524 | Image(systemName: subscribed ? "bell.fill" : "bell") | |
| 525 | } | |
| 526 | } | |
| 527 | .disabled(viewModel.isTogglingSubscription) | |
| 528 | .accessibilityLabel(subscribed ? "Unsubscribe from list" : "Subscribe to list") | |
| 529 | } | |
| 530 | } | |
| 389 | 531 | } |
| 390 | 532 | .task { |
| 391 | 533 | if viewModel == nil { |
| @@ -397,6 +539,7 @@ struct MailingListDetailView: View { | ||
| 397 | 539 | ) |
| 398 | 540 | self.viewModel = viewModel |
| 399 | 541 | await viewModel.loadThreads() |
| 542 | await viewModel.loadSubscriptionState(currentUserCanonicalName: currentUserKey) | |
| 400 | 543 | } |
| 401 | 544 | } |
| 402 | 545 | .onAppear { |
ROADMAP.txt +34 −19
| @@ -27,6 +27,15 @@ Things the schema does not tell you, each of which has already cost real time. | ||
| 27 | 27 | `git clone --depth 1 https://git.sr.ht/~sircmpwn/<service>.sr.ht` and look at |
| 28 | 28 | `api/graph/schema.graphqls`. Regenerating the dumps with a full introspection |
| 29 | 29 | query would remove the trap. |
| 30 | - **`MailingList.subscription` does not report your subscription.** The field | |
| 31 | exists and is typed `MailingListSubscription`, but it returns null even | |
| 32 | immediately after a `mailingListSubscribe` that hands you back a real | |
| 33 | subscription id — verified live against `~hutch`, for both owned and | |
| 34 | non-owned lists. Do not gate subscribe-state on it. The authoritative source | |
| 35 | is membership in the `subscriptions` query (correct: true after subscribe, | |
| 36 | false after unsubscribe); the mutations take `listID: Int!`, read from | |
| 37 | `list(rid:){ id }`. Cost the v3.11.0 subscribe toggle a full afternoon of the | |
| 38 | "looks right, isn't" variety. | |
| 30 | 39 | |
| 31 | 40 | ## Phase 0: Unblock CI — done (v3.5.0) |
| 32 | 41 | |
| @@ -79,14 +88,14 @@ were never called. Each removes a "why can't I do this here?" moment. | ||
| 79 | 88 | - ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and |
| 80 | 89 | `copySelf`, surfaced as an Email section in Settings. |
| 81 | 90 | |
| 82 | `mailingListSubscribe` was left unwired here because `MailingList` had no | |
| 83 | `subscription` field, unlike `Ticket` and `Tracker`, so per-list state was only | |
| 84 | knowable from the `subscriptions` query — which by definition lists what the | |
| 85 | user is already subscribed to. **That has since changed:** live introspection | |
| 86 | during the ingest shows `MailingList.subscription` now exists (type | |
| 87 | `MailingListSubscription`, present when subscribed), so per-list state is | |
| 88 | readable and the `mailingListSubscribe` / `mailingListUnsubscribe` toggle can | |
| 89 | reflect it. Now buildable — see "mailing list subscribe" below. | |
| 91 | `mailingListSubscribe` was left unwired here on the view that per-list state was | |
| 92 | only knowable from the `subscriptions` query, and subscribing needs a list you | |
| 93 | are *not* subscribed to. **Shipped in v3.11.0** once live testing clarified two | |
| 94 | things: a specific list is reachable without a discovery API (Lookup, a project's | |
| 95 | lists, patchsets), and the `subscriptions` query *is* the reliable state source — | |
| 96 | membership in it answers "am I subscribed to this rid?". `MailingList.subscription` | |
| 97 | looked like a shortcut but is a trap (see API traps); it is not used. See | |
| 98 | "mailing list subscribe" below. | |
| 90 | 99 | |
| 91 | 100 | ### Refactors folded in |
| 92 | 101 | |
| @@ -146,7 +155,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*. | ||
| 146 | 155 | | v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface | |
| 147 | 156 | | v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session | |
| 148 | 157 | | v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); ~~"What's cooking" ingest + doc truth-up~~ (done) | Ships one feature, corrects the map | |
| 149 | | v3.11.0 | Mailing list subscribe/unsubscribe toggle | Ingest-surfaced; `MailingList.subscription` now exists | | |
| 158 | | v3.11.0 | ~~Mailing list subscribe/unsubscribe toggle~~ (shipped) | Ingest-surfaced; state via the `subscriptions` query (the `subscription` field is a trap) | | |
| 150 | 159 | | v3.12.0 | Accessibility | Independent, device-verified | |
| 151 | 160 | | v4.0.0 | Localization *with* translations | The only true re-presentation | |
| 152 | 161 | | — | Swift 6 language mode; cache reads | Internal; ride along, no tag | |
| @@ -316,16 +325,22 @@ Planned-but-not-yet-shipped upstream, so nothing to build: anonymous API access | ||
| 316 | 325 | and "standardized / connections-spec" GraphQL (Q2 named both as future work). |
| 317 | 326 | The one *new* opening the introspection turned up is below. |
| 318 | 327 | |
| 319 | ### Mailing list subscribe — buildable | |
| 320 | ||
| 321 | `MailingList` gained a `subscription` field (type `MailingListSubscription`, | |
| 322 | present when the viewer is subscribed) — confirmed live. That removes the exact | |
| 323 | blocker Phase 1 named: per-list state is now readable, so a subscribe / | |
| 324 | unsubscribe toggle can reflect real server state using the existing | |
| 325 | `mailingListSubscribe` / `mailingListUnsubscribe` mutations (unsubscribe already | |
| 326 | shipped in Phase 1). Surface it wherever a list is shown — Lookup results, | |
| 327 | `ProjectMailingListView`, the inbox list header. Small, self-contained write | |
| 328 | feature; its own minor. | |
| 328 | ### Mailing list subscribe — done (v3.11.0) | |
| 329 | ||
| 330 | A subscribe / unsubscribe toggle now sits in the mailing-list detail toolbar | |
| 331 | (`MailingListDetailView`, which backs both Lookup results and | |
| 332 | `ProjectMailingListView`). It is hidden for lists you own and while state is | |
| 333 | unknown. | |
| 334 | ||
| 335 | Live testing rewrote the plan. `MailingList.subscription` looked like the state | |
| 336 | source but is a trap — it returns null even right after a successful | |
| 337 | `mailingListSubscribe` that hands back a subscription id (see API traps). So | |
| 338 | state comes from membership in the `subscriptions` query, which *is* reliable, | |
| 339 | and the numeric `listID` the mutations require comes from `list(rid:){ id }`. | |
| 340 | The mutations themselves (`mailingListSubscribe` / `mailingListUnsubscribe`, | |
| 341 | `listID: Int!`) work as expected. Phase 1's "no discovery API" worry was moot: | |
| 342 | a specific list is reachable via Lookup, a project's lists, or patchsets, and | |
| 343 | that is all subscribing needs. | |
| 329 | 344 | |
| 330 | 345 | ### hub.sr.ht writes — projects and discovery done |
| 331 | 346 | |
SCOPE.txt +5 −4
| @@ -31,10 +31,11 @@ | ||
| 31 | 31 | unaffected because it filters on `ev.ticket_id`, which is why ticket timelines |
| 32 | 32 | work. Nothing a client can do fixes this; revisit only if sr.ht changes the |
| 33 | 33 | resolver. |
| 34 | - ~~Subscribe to a mailing list~~ — **now buildable**. `MailingList` gained a | |
| 35 | `subscription` field (confirmed live during the Q1–Q2 2026 ingest), so per-list | |
| 36 | subscribe state is readable and `mailingListSubscribe` / `mailingListUnsubscribe` | |
| 37 | can drive a real toggle. Moved to ROADMAP.txt ("mailing list subscribe"). | |
| 34 | - ~~Subscribe to a mailing list~~ — **shipped** (v3.11.0). A subscribe/unsubscribe | |
| 35 | toggle on the mailing-list detail view. State comes from the `subscriptions` | |
| 36 | query (`MailingList.subscription` is a trap — it never reports your state; see | |
| 37 | ROADMAP.txt API traps), and a list is reachable to subscribe to via Lookup or a | |
| 38 | project's lists — no discovery API needed. | |
| 38 | 39 | - Submitting patches (a `git send-email` flow, not a GraphQL mutation; Hutch |
| 39 | 40 | reviews patchsets but cannot send them) |
| 40 | 41 | |