Commit 8f824f9873

8f824f9873cd5a3a76466b0bb24cb1c953df3f7f

parent: c4357a69b7

Unsigned

cmc <hello@cleberg.net> · 2026-08-07 21:51 UTC

Mailing list subscribe/unsubscribe toggle (v3.11.0)

Add a subscribe/unsubscribe toggle to the mailing-list detail toolbar
(MailingListDetailView, which backs Lookup results and ProjectMailingListView).
Hidden for lists you own and while state is unknown.

State comes from membership in the subscriptions query, not
MailingList.subscription — live testing showed that field returns null even
right after a successful mailingListSubscribe, so it is a trap (documented in
ROADMAP API traps). The numeric listID the mutations require is read from
list(rid:){ id }. Verified live against ~hutch.

Bumps to 3.11.0 (build 96); roadmap/SCOPE corrected.

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
40private struct ListMetaResponse: Decodable, Sendable {
41 let list: ListMetaPayload?
42}
43
44private struct ListMetaPayload: Decodable, Sendable {
45 let id: Int
46 let owner: Entity
47}
48
49private struct SubscriptionRidsResponse: Decodable, Sendable {
50 let subscriptions: SubscriptionRidsPage
51}
52
53private struct SubscriptionRidsPage: Decodable, Sendable {
54 let results: [SubscriptionRidEntry]
55 let cursor: String?
56}
57
58private struct SubscriptionRidEntry: Decodable, Sendable {
59 let list: SubscriptionRidList?
60}
61
62private struct SubscriptionRidList: Decodable, Sendable {
63 let rid: String
64}
65
66/// Toggle mutations return the subscription (nullable on unsubscribe); only
67/// success matters.
68private struct SubscriptionToggleResponse: Decodable, Sendable {}
69
40@Observable 70@Observable
41@MainActor 71@MainActor
42final class MailingListDetailViewModel { 72final 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 92only knowable from the `subscriptions` query, and subscribing needs a list you
84knowable from the `subscriptions` query — which by definition lists what the 93are *not* subscribed to. **Shipped in v3.11.0** once live testing clarified two
85user is already subscribed to. **That has since changed:** live introspection 94things: a specific list is reachable without a discovery API (Lookup, a project's
86during the ingest shows `MailingList.subscription` now exists (type 95lists, patchsets), and the `subscriptions` query *is* the reliable state source —
87`MailingListSubscription`, present when subscribed), so per-list state is 96membership in it answers "am I subscribed to this rid?". `MailingList.subscription`
88readable and the `mailingListSubscribe` / `mailingListUnsubscribe` toggle can 97looked like a shortcut but is a trap (see API traps); it is not used. See
89reflect 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
316and "standardized / connections-spec" GraphQL (Q2 named both as future work). 325and "standardized / connections-spec" GraphQL (Q2 named both as future work).
317The one *new* opening the introspection turned up is below. 326The 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`, 330A subscribe / unsubscribe toggle now sits in the mailing-list detail toolbar
322present when the viewer is subscribed) — confirmed live. That removes the exact 331(`MailingListDetailView`, which backs both Lookup results and
323blocker 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
324unsubscribe toggle can reflect real server state using the existing 333unknown.
325`mailingListSubscribe` / `mailingListUnsubscribe` mutations (unsubscribe already 334
326shipped in Phase 1). Surface it wherever a list is shown — Lookup results, 335Live testing rewrote the plan. `MailingList.subscription` looked like the state
327`ProjectMailingListView`, the inbox list header. Small, self-contained write 336source but is a trap — it returns null even right after a successful
328feature; its own minor. 337`mailingListSubscribe` that hands back a subscription id (see API traps). So
338state comes from membership in the `subscriptions` query, which *is* reliable,
339and the numeric `listID` the mutations require comes from `list(rid:){ id }`.
340The mutations themselves (`mailingListSubscribe` / `mailingListUnsubscribe`,
341`listID: Int!`) work as expected. Phase 1's "no discovery API" worry was moot:
342a specific list is reachable via Lookup, a project's lists, or patchsets, and
343that 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