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 @@
597597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
598598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
599599 CODE_SIGN_STYLE = Automatic;
600 CURRENT_PROJECT_VERSION = 95;
600 CURRENT_PROJECT_VERSION = 96;
601601 DEVELOPMENT_TEAM = ZCNAX3VL9D;
602602 ENABLE_PREVIEWS = YES;
603603 GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
614614 "$(inherited)",
615615 "@executable_path/Frameworks",
616616 );
617 MARKETING_VERSION = 3.10.0;
617 MARKETING_VERSION = 3.11.0;
618618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
619619 PRODUCT_NAME = "$(TARGET_NAME)";
620620 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
634634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
635635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
636636 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 95;
637 CURRENT_PROJECT_VERSION = 96;
638638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639639 ENABLE_PREVIEWS = YES;
640640 GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
651651 "$(inherited)",
652652 "@executable_path/Frameworks",
653653 );
654 MARKETING_VERSION = 3.10.0;
654 MARKETING_VERSION = 3.11.0;
655655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
656656 PRODUCT_NAME = "$(TARGET_NAME)";
657657 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
714714 APPLICATION_EXTENSION_API_ONLY = YES;
715715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
716716 CODE_SIGN_STYLE = Automatic;
717 CURRENT_PROJECT_VERSION = 95;
717 CURRENT_PROJECT_VERSION = 96;
718718 DEVELOPMENT_TEAM = ZCNAX3VL9D;
719719 GENERATE_INFOPLIST_FILE = NO;
720720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
724724 "@executable_path/Frameworks",
725725 "@executable_path/../../Frameworks",
726726 );
727 MARKETING_VERSION = 3.10.0;
727 MARKETING_VERSION = 3.11.0;
728728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
729729 PRODUCT_NAME = "$(TARGET_NAME)";
730730 SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
743743 APPLICATION_EXTENSION_API_ONLY = YES;
744744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
745745 CODE_SIGN_STYLE = Automatic;
746 CURRENT_PROJECT_VERSION = 95;
746 CURRENT_PROJECT_VERSION = 96;
747747 DEVELOPMENT_TEAM = ZCNAX3VL9D;
748748 GENERATE_INFOPLIST_FILE = NO;
749749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
753753 "@executable_path/Frameworks",
754754 "@executable_path/../../Frameworks",
755755 );
756 MARKETING_VERSION = 3.10.0;
756 MARKETING_VERSION = 3.11.0;
757757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
758758 PRODUCT_NAME = "$(TARGET_NAME)";
759759 SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
772772 APPLICATION_EXTENSION_API_ONLY = YES;
773773 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
774774 CODE_SIGN_STYLE = Automatic;
775 CURRENT_PROJECT_VERSION = 95;
775 CURRENT_PROJECT_VERSION = 96;
776776 DEVELOPMENT_TEAM = ZCNAX3VL9D;
777777 GENERATE_INFOPLIST_FILE = NO;
778778 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
782782 "@executable_path/Frameworks",
783783 "@executable_path/../../Frameworks",
784784 );
785 MARKETING_VERSION = 3.10.0;
785 MARKETING_VERSION = 3.11.0;
786786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
787787 PRODUCT_NAME = "$(TARGET_NAME)";
788788 SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
801801 APPLICATION_EXTENSION_API_ONLY = YES;
802802 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
803803 CODE_SIGN_STYLE = Automatic;
804 CURRENT_PROJECT_VERSION = 95;
804 CURRENT_PROJECT_VERSION = 96;
805805 DEVELOPMENT_TEAM = ZCNAX3VL9D;
806806 GENERATE_INFOPLIST_FILE = NO;
807807 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
811811 "@executable_path/Frameworks",
812812 "@executable_path/../../Frameworks",
813813 );
814 MARKETING_VERSION = 3.10.0;
814 MARKETING_VERSION = 3.11.0;
815815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
816816 PRODUCT_NAME = "$(TARGET_NAME)";
817817 SKIP_INSTALL = YES;
Hutch/Views/Projects/ProjectMailingListView.swift +143
@@ -37,6 +37,36 @@ private struct PatchsetSummaryPayload: Decodable, Sendable {
3737 let status: PatchsetStatus
3838}
3939
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
4070@Observable
4171@MainActor
4272final class MailingListDetailViewModel {
@@ -47,6 +77,13 @@ final class MailingListDetailViewModel {
4777 var error: String?
4878 var searchText = ""
4979
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
5087 private let mailingList: InboxMailingListReference
5188 private let client: SRHTClient
5289 private let defaults: UserDefaults
@@ -86,6 +123,96 @@ final class MailingListDetailViewModel {
86123 self.accountID = accountID
87124 }
88125
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
89216 var filteredThreads: [InboxThreadSummary] {
90217 Self.filterThreads(threads, matching: searchText)
91218 }
@@ -386,6 +513,21 @@ struct MailingListDetailView: View {
386513 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
387514 }
388515 }
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 }
389531 }
390532 .task {
391533 if viewModel == nil {
@@ -397,6 +539,7 @@ struct MailingListDetailView: View {
397539 )
398540 self.viewModel = viewModel
399541 await viewModel.loadThreads()
542 await viewModel.loadSubscriptionState(currentUserCanonicalName: currentUserKey)
400543 }
401544 }
402545 .onAppear {
ROADMAP.txt +34 −19
@@ -27,6 +27,15 @@ Things the schema does not tell you, each of which has already cost real time.
2727 `git clone --depth 1 https://git.sr.ht/~sircmpwn/<service>.sr.ht` and look at
2828 `api/graph/schema.graphqls`. Regenerating the dumps with a full introspection
2929 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.
3039
3140## Phase 0: Unblock CI — done (v3.5.0)
3241
@@ -79,14 +88,14 @@ were never called. Each removes a "why can't I do this here?" moment.
7988- ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and
8089 `copySelf`, surfaced as an Email section in Settings.
8190
82`mailingListSubscribe` was left unwired here because `MailingList` had no
83`subscription` field, unlike `Ticket` and `Tracker`, so per-list state was only
84knowable from the `subscriptions` query — which by definition lists what the
85user is already subscribed to. **That has since changed:** live introspection
86during the ingest shows `MailingList.subscription` now exists (type
87`MailingListSubscription`, present when subscribed), so per-list state is
88readable and the `mailingListSubscribe` / `mailingListUnsubscribe` toggle can
89reflect it. Now buildable — see "mailing list subscribe" below.
91`mailingListSubscribe` was left unwired here on the view that per-list state was
92only knowable from the `subscriptions` query, and subscribing needs a list you
93are *not* subscribed to. **Shipped in v3.11.0** once live testing clarified two
94things: a specific list is reachable without a discovery API (Lookup, a project's
95lists, patchsets), and the `subscriptions` query *is* the reliable state source —
96membership in it answers "am I subscribed to this rid?". `MailingList.subscription`
97looked like a shortcut but is a trap (see API traps); it is not used. See
98"mailing list subscribe" below.
9099
91100### Refactors folded in
92101
@@ -146,7 +155,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*.
146155| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
147156| 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 |
148157| 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) |
150159| v3.12.0 | Accessibility | Independent, device-verified |
151160| v4.0.0 | Localization *with* translations | The only true re-presentation |
152161| — | 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
316325and "standardized / connections-spec" GraphQL (Q2 named both as future work).
317326The one *new* opening the introspection turned up is below.
318327
319### Mailing list subscribe — buildable
320
321`MailingList` gained a `subscription` field (type `MailingListSubscription`,
322present when the viewer is subscribed) — confirmed live. That removes the exact
323blocker Phase 1 named: per-list state is now readable, so a subscribe /
324unsubscribe toggle can reflect real server state using the existing
325`mailingListSubscribe` / `mailingListUnsubscribe` mutations (unsubscribe already
326shipped in Phase 1). Surface it wherever a list is shown — Lookup results,
327`ProjectMailingListView`, the inbox list header. Small, self-contained write
328feature; its own minor.
328### Mailing list subscribe — done (v3.11.0)
329
330A 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
333unknown.
334
335Live testing rewrote the plan. `MailingList.subscription` looked like the state
336source but is a trap — it returns null even right after a successful
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.
329344
330345### hub.sr.ht writes — projects and discovery done
331346
SCOPE.txt +5 −4
@@ -31,10 +31,11 @@
3131 unaffected because it filters on `ev.ticket_id`, which is why ticket timelines
3232 work. Nothing a client can do fixes this; revisit only if sr.ht changes the
3333 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.
3839- Submitting patches (a `git send-email` flow, not a GraphQL mutation; Hutch
3940 reviews patchsets but cannot send them)
4041