Commit 8f824f9873
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +12 −12
| @@ -597,7 +597,7 @@ | |||
| 597 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 597 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 598 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 598 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 599 | CODE_SIGN_STYLE = Automatic; | 599 | CODE_SIGN_STYLE = Automatic; |
| 600 | CURRENT_PROJECT_VERSION = 95; | 600 | CURRENT_PROJECT_VERSION = 96; |
| 601 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 601 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 602 | ENABLE_PREVIEWS = YES; | 602 | ENABLE_PREVIEWS = YES; |
| 603 | GENERATE_INFOPLIST_FILE = YES; | 603 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -614,7 +614,7 @@ | |||
| 614 | "$(inherited)", | 614 | "$(inherited)", |
| 615 | "@executable_path/Frameworks", | 615 | "@executable_path/Frameworks", |
| 616 | ); | 616 | ); |
| 617 | MARKETING_VERSION = 3.10.0; | 617 | MARKETING_VERSION = 3.11.0; |
| 618 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 618 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 619 | PRODUCT_NAME = "$(TARGET_NAME)"; | 619 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 620 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 620 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -634,7 +634,7 @@ | |||
| 634 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 634 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 635 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 635 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 636 | CODE_SIGN_STYLE = Automatic; | 636 | CODE_SIGN_STYLE = Automatic; |
| 637 | CURRENT_PROJECT_VERSION = 95; | 637 | CURRENT_PROJECT_VERSION = 96; |
| 638 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 638 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 639 | ENABLE_PREVIEWS = YES; | 639 | ENABLE_PREVIEWS = YES; |
| 640 | GENERATE_INFOPLIST_FILE = YES; | 640 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -651,7 +651,7 @@ | |||
| 651 | "$(inherited)", | 651 | "$(inherited)", |
| 652 | "@executable_path/Frameworks", | 652 | "@executable_path/Frameworks", |
| 653 | ); | 653 | ); |
| 654 | MARKETING_VERSION = 3.10.0; | 654 | MARKETING_VERSION = 3.11.0; |
| 655 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 655 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 656 | PRODUCT_NAME = "$(TARGET_NAME)"; | 656 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 657 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 657 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -714,7 +714,7 @@ | |||
| 714 | APPLICATION_EXTENSION_API_ONLY = YES; | 714 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 715 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 715 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 716 | CODE_SIGN_STYLE = Automatic; | 716 | CODE_SIGN_STYLE = Automatic; |
| 717 | CURRENT_PROJECT_VERSION = 95; | 717 | CURRENT_PROJECT_VERSION = 96; |
| 718 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 718 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 719 | GENERATE_INFOPLIST_FILE = NO; | 719 | GENERATE_INFOPLIST_FILE = NO; |
| 720 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 720 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -724,7 +724,7 @@ | |||
| 724 | "@executable_path/Frameworks", | 724 | "@executable_path/Frameworks", |
| 725 | "@executable_path/../../Frameworks", | 725 | "@executable_path/../../Frameworks", |
| 726 | ); | 726 | ); |
| 727 | MARKETING_VERSION = 3.10.0; | 727 | MARKETING_VERSION = 3.11.0; |
| 728 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 728 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 729 | PRODUCT_NAME = "$(TARGET_NAME)"; | 729 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 730 | SKIP_INSTALL = YES; | 730 | SKIP_INSTALL = YES; |
| @@ -743,7 +743,7 @@ | |||
| 743 | APPLICATION_EXTENSION_API_ONLY = YES; | 743 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 744 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 744 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 745 | CODE_SIGN_STYLE = Automatic; | 745 | CODE_SIGN_STYLE = Automatic; |
| 746 | CURRENT_PROJECT_VERSION = 95; | 746 | CURRENT_PROJECT_VERSION = 96; |
| 747 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 747 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 748 | GENERATE_INFOPLIST_FILE = NO; | 748 | GENERATE_INFOPLIST_FILE = NO; |
| 749 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 749 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -753,7 +753,7 @@ | |||
| 753 | "@executable_path/Frameworks", | 753 | "@executable_path/Frameworks", |
| 754 | "@executable_path/../../Frameworks", | 754 | "@executable_path/../../Frameworks", |
| 755 | ); | 755 | ); |
| 756 | MARKETING_VERSION = 3.10.0; | 756 | MARKETING_VERSION = 3.11.0; |
| 757 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 757 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 758 | PRODUCT_NAME = "$(TARGET_NAME)"; | 758 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 759 | SKIP_INSTALL = YES; | 759 | SKIP_INSTALL = YES; |
| @@ -772,7 +772,7 @@ | |||
| 772 | APPLICATION_EXTENSION_API_ONLY = YES; | 772 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 773 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 773 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 774 | CODE_SIGN_STYLE = Automatic; | 774 | CODE_SIGN_STYLE = Automatic; |
| 775 | CURRENT_PROJECT_VERSION = 95; | 775 | CURRENT_PROJECT_VERSION = 96; |
| 776 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 776 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 777 | GENERATE_INFOPLIST_FILE = NO; | 777 | GENERATE_INFOPLIST_FILE = NO; |
| 778 | INFOPLIST_FILE = HutchSafariExtension/Info.plist; | 778 | INFOPLIST_FILE = HutchSafariExtension/Info.plist; |
| @@ -782,7 +782,7 @@ | |||
| 782 | "@executable_path/Frameworks", | 782 | "@executable_path/Frameworks", |
| 783 | "@executable_path/../../Frameworks", | 783 | "@executable_path/../../Frameworks", |
| 784 | ); | 784 | ); |
| 785 | MARKETING_VERSION = 3.10.0; | 785 | MARKETING_VERSION = 3.11.0; |
| 786 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; | 786 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; |
| 787 | PRODUCT_NAME = "$(TARGET_NAME)"; | 787 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 788 | SKIP_INSTALL = YES; | 788 | SKIP_INSTALL = YES; |
| @@ -801,7 +801,7 @@ | |||
| 801 | APPLICATION_EXTENSION_API_ONLY = YES; | 801 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 802 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 802 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 803 | CODE_SIGN_STYLE = Automatic; | 803 | CODE_SIGN_STYLE = Automatic; |
| 804 | CURRENT_PROJECT_VERSION = 95; | 804 | CURRENT_PROJECT_VERSION = 96; |
| 805 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 805 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 806 | GENERATE_INFOPLIST_FILE = NO; | 806 | GENERATE_INFOPLIST_FILE = NO; |
| 807 | INFOPLIST_FILE = HutchSafariExtension/Info.plist; | 807 | INFOPLIST_FILE = HutchSafariExtension/Info.plist; |
| @@ -811,7 +811,7 @@ | |||
| 811 | "@executable_path/Frameworks", | 811 | "@executable_path/Frameworks", |
| 812 | "@executable_path/../../Frameworks", | 812 | "@executable_path/../../Frameworks", |
| 813 | ); | 813 | ); |
| 814 | MARKETING_VERSION = 3.10.0; | 814 | MARKETING_VERSION = 3.11.0; |
| 815 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; | 815 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; |
| 816 | PRODUCT_NAME = "$(TARGET_NAME)"; | 816 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 817 | SKIP_INSTALL = YES; | 817 | SKIP_INSTALL = YES; |
Hutch/Views/Projects/ProjectMailingListView.swift +143
| @@ -37,6 +37,36 @@ private struct PatchsetSummaryPayload: Decodable, Sendable { | |||
| 37 | let status: PatchsetStatus | 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 | @Observable | 70 | @Observable |
| 41 | @MainActor | 71 | @MainActor |
| 42 | final class MailingListDetailViewModel { | 72 | final class MailingListDetailViewModel { |
| @@ -47,6 +77,13 @@ final class MailingListDetailViewModel { | |||
| 47 | var error: String? | 77 | var error: String? |
| 48 | var searchText = "" | 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 | private let mailingList: InboxMailingListReference | 87 | private let mailingList: InboxMailingListReference |
| 51 | private let client: SRHTClient | 88 | private let client: SRHTClient |
| 52 | private let defaults: UserDefaults | 89 | private let defaults: UserDefaults |
| @@ -86,6 +123,96 @@ final class MailingListDetailViewModel { | |||
| 86 | self.accountID = accountID | 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 | var filteredThreads: [InboxThreadSummary] { | 216 | var filteredThreads: [InboxThreadSummary] { |
| 90 | Self.filterThreads(threads, matching: searchText) | 217 | Self.filterThreads(threads, matching: searchText) |
| 91 | } | 218 | } |
| @@ -386,6 +513,21 @@ struct MailingListDetailView: View { | |||
| 386 | .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") | 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 | .task { | 532 | .task { |
| 391 | if viewModel == nil { | 533 | if viewModel == nil { |
| @@ -397,6 +539,7 @@ struct MailingListDetailView: View { | |||
| 397 | ) | 539 | ) |
| 398 | self.viewModel = viewModel | 540 | self.viewModel = viewModel |
| 399 | await viewModel.loadThreads() | 541 | await viewModel.loadThreads() |
| 542 | await viewModel.loadSubscriptionState(currentUserCanonicalName: currentUserKey) | ||
| 400 | } | 543 | } |
| 401 | } | 544 | } |
| 402 | .onAppear { | 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 | `git clone --depth 1 https://git.sr.ht/~sircmpwn/<service>.sr.ht` and look at | 27 | `git clone --depth 1 https://git.sr.ht/~sircmpwn/<service>.sr.ht` and look at |
| 28 | `api/graph/schema.graphqls`. Regenerating the dumps with a full introspection | 28 | `api/graph/schema.graphqls`. Regenerating the dumps with a full introspection |
| 29 | query would remove the trap. | 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 | ## Phase 0: Unblock CI — done (v3.5.0) | 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 | - ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and | 88 | - ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and |
| 80 | `copySelf`, surfaced as an Email section in Settings. | 89 | `copySelf`, surfaced as an Email section in Settings. |
| 81 | 90 | ||
| 82 | `mailingListSubscribe` was left unwired here because `MailingList` had no | 91 | `mailingListSubscribe` was left unwired here on the view that per-list state was |
| 83 | `subscription` field, unlike `Ticket` and `Tracker`, so per-list state was only | 92 | only knowable from the `subscriptions` query, and subscribing needs a list you |
| 84 | knowable from the `subscriptions` query — which by definition lists what the | 93 | are *not* subscribed to. **Shipped in v3.11.0** once live testing clarified two |
| 85 | user is already subscribed to. **That has since changed:** live introspection | 94 | things: a specific list is reachable without a discovery API (Lookup, a project's |
| 86 | during the ingest shows `MailingList.subscription` now exists (type | 95 | lists, patchsets), and the `subscriptions` query *is* the reliable state source — |
| 87 | `MailingListSubscription`, present when subscribed), so per-list state is | 96 | membership in it answers "am I subscribed to this rid?". `MailingList.subscription` |
| 88 | readable and the `mailingListSubscribe` / `mailingListUnsubscribe` toggle can | 97 | looked like a shortcut but is a trap (see API traps); it is not used. See |
| 89 | reflect it. Now buildable — see "mailing list subscribe" below. | 98 | "mailing list subscribe" below. |
| 90 | 99 | ||
| 91 | ### Refactors folded in | 100 | ### Refactors folded in |
| 92 | 101 | ||
| @@ -146,7 +155,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*. | |||
| 146 | | v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface | | 155 | | v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface | |
| 147 | | 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 | | 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 | | v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); ~~"What's cooking" ingest + doc truth-up~~ (done) | Ships one feature, corrects the map | | 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 | | v3.12.0 | Accessibility | Independent, device-verified | | 159 | | v3.12.0 | Accessibility | Independent, device-verified | |
| 151 | | v4.0.0 | Localization *with* translations | The only true re-presentation | | 160 | | v4.0.0 | Localization *with* translations | The only true re-presentation | |
| 152 | | — | Swift 6 language mode; cache reads | Internal; ride along, no tag | | 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 | and "standardized / connections-spec" GraphQL (Q2 named both as future work). | 325 | and "standardized / connections-spec" GraphQL (Q2 named both as future work). |
| 317 | The one *new* opening the introspection turned up is below. | 326 | The one *new* opening the introspection turned up is below. |
| 318 | 327 | ||
| 319 | ### Mailing list subscribe — buildable | 328 | ### Mailing list subscribe — done (v3.11.0) |
| 320 | 329 | ||
| 321 | `MailingList` gained a `subscription` field (type `MailingListSubscription`, | 330 | A subscribe / unsubscribe toggle now sits in the mailing-list detail toolbar |
| 322 | present when the viewer is subscribed) — confirmed live. That removes the exact | 331 | (`MailingListDetailView`, which backs both Lookup results and |
| 323 | blocker Phase 1 named: per-list state is now readable, so a subscribe / | 332 | `ProjectMailingListView`). It is hidden for lists you own and while state is |
| 324 | unsubscribe toggle can reflect real server state using the existing | 333 | unknown. |
| 325 | `mailingListSubscribe` / `mailingListUnsubscribe` mutations (unsubscribe already | 334 | |
| 326 | shipped in Phase 1). Surface it wherever a list is shown — Lookup results, | 335 | Live testing rewrote the plan. `MailingList.subscription` looked like the state |
| 327 | `ProjectMailingListView`, the inbox list header. Small, self-contained write | 336 | source but is a trap — it returns null even right after a successful |
| 328 | feature; its own minor. | 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 | ### hub.sr.ht writes — projects and discovery done | 345 | ### hub.sr.ht writes — projects and discovery done |
| 331 | 346 | ||
SCOPE.txt +5 −4
| @@ -31,10 +31,11 @@ | |||
| 31 | unaffected because it filters on `ev.ticket_id`, which is why ticket timelines | 31 | unaffected because it filters on `ev.ticket_id`, which is why ticket timelines |
| 32 | work. Nothing a client can do fixes this; revisit only if sr.ht changes the | 32 | work. Nothing a client can do fixes this; revisit only if sr.ht changes the |
| 33 | resolver. | 33 | resolver. |
| 34 | - ~~Subscribe to a mailing list~~ — **now buildable**. `MailingList` gained a | 34 | - ~~Subscribe to a mailing list~~ — **shipped** (v3.11.0). A subscribe/unsubscribe |
| 35 | `subscription` field (confirmed live during the Q1–Q2 2026 ingest), so per-list | 35 | toggle on the mailing-list detail view. State comes from the `subscriptions` |
| 36 | subscribe state is readable and `mailingListSubscribe` / `mailingListUnsubscribe` | 36 | query (`MailingList.subscription` is a trap — it never reports your state; see |
| 37 | can drive a real toggle. Moved to ROADMAP.txt ("mailing list subscribe"). | 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 | - Submitting patches (a `git send-email` flow, not a GraphQL mutation; Hutch | 39 | - Submitting patches (a `git send-email` flow, not a GraphQL mutation; Hutch |
| 39 | reviews patchsets but cannot send them) | 40 | reviews patchsets but cannot send them) |
| 40 | 41 | ||