krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

039f06095cb3df072968be2f19f45bace605baeb

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-07T21:51:32Z

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.
 Hutch.xcodeproj/project.pbxproj                   |  24 ++--
 Hutch/Views/Projects/ProjectMailingListView.swift | 143 ++++++++++++++++++++++
 ROADMAP.txt                                       |  53 +++++---
 SCOPE.txt                                         |   9 +-
 4 files changed, 194 insertions(+), 35 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 48a1c0f..dfa2b62 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -597,7 +597,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 95;
+				CURRENT_PROJECT_VERSION = 96;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.10.0;
+				MARKETING_VERSION = 3.11.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 95;
+				CURRENT_PROJECT_VERSION = 96;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.10.0;
+				MARKETING_VERSION = 3.11.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 95;
+				CURRENT_PROJECT_VERSION = 96;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.10.0;
+				MARKETING_VERSION = 3.11.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 95;
+				CURRENT_PROJECT_VERSION = 96;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.10.0;
+				MARKETING_VERSION = 3.11.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 95;
+				CURRENT_PROJECT_VERSION = 96;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.10.0;
+				MARKETING_VERSION = 3.11.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 95;
+				CURRENT_PROJECT_VERSION = 96;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.10.0;
+				MARKETING_VERSION = 3.11.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index 10bb19e..a4decbb 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -37,6 +37,36 @@ private struct PatchsetSummaryPayload: Decodable, Sendable {
     let status: PatchsetStatus
 }
 
+private struct ListMetaResponse: Decodable, Sendable {
+    let list: ListMetaPayload?
+}
+
+private struct ListMetaPayload: Decodable, Sendable {
+    let id: Int
+    let owner: Entity
+}
+
+private struct SubscriptionRidsResponse: Decodable, Sendable {
+    let subscriptions: SubscriptionRidsPage
+}
+
+private struct SubscriptionRidsPage: Decodable, Sendable {
+    let results: [SubscriptionRidEntry]
+    let cursor: String?
+}
+
+private struct SubscriptionRidEntry: Decodable, Sendable {
+    let list: SubscriptionRidList?
+}
+
+private struct SubscriptionRidList: Decodable, Sendable {
+    let rid: String
+}
+
+/// Toggle mutations return the subscription (nullable on unsubscribe); only
+/// success matters.
+private struct SubscriptionToggleResponse: Decodable, Sendable {}
+
 @Observable
 @MainActor
 final class MailingListDetailViewModel {
@@ -47,6 +77,13 @@ final class MailingListDetailViewModel {
     var error: String?
     var searchText = ""
 
+    /// Subscription state. `isSubscribed` is `nil` while unknown or unavailable
+    /// (the toggle stays hidden); `isOwnList` hides it for lists you own.
+    private(set) var listNumericID: Int?
+    private(set) var isSubscribed: Bool?
+    private(set) var isOwnList = false
+    private(set) var isTogglingSubscription = false
+
     private let mailingList: InboxMailingListReference
     private let client: SRHTClient
     private let defaults: UserDefaults
@@ -86,6 +123,96 @@ final class MailingListDetailViewModel {
         self.accountID = accountID
     }
 
+    // MARK: - Subscription
+
+    private static let listMetaQuery = """
+    query listMeta($rid: ID!) {
+        list(rid: $rid) { id owner { canonicalName } }
+    }
+    """
+
+    // `MailingList.subscription` is unreliable (see the API-traps note), so
+    // subscribe state comes from the authoritative `subscriptions` query.
+    private static let subscriptionRidsQuery = """
+    query subscriptionRids($cursor: Cursor) {
+        subscriptions(cursor: $cursor) {
+            results {
+                ... on MailingListSubscription { list { rid } }
+            }
+            cursor
+        }
+    }
+    """
+
+    private static let subscribeMutation = """
+    mutation mailingListSubscribe($id: Int!) {
+        mailingListSubscribe(listID: $id) { id }
+    }
+    """
+
+    private static let unsubscribeMutation = """
+    mutation mailingListUnsubscribe($id: Int!) {
+        mailingListUnsubscribe(listID: $id) { id }
+    }
+    """
+
+    /// Resolves the list's numeric id, whether the viewer owns it, and — for
+    /// lists they don't own — whether they're subscribed.
+    func loadSubscriptionState(currentUserCanonicalName: String?) async {
+        do {
+            let meta = try await client.execute(
+                service: .lists,
+                query: Self.listMetaQuery,
+                variables: ["rid": mailingList.rid],
+                responseType: ListMetaResponse.self
+            )
+            guard let list = meta.list else { return }
+            listNumericID = list.id
+
+            if let currentUserCanonicalName, list.owner.canonicalName == currentUserCanonicalName {
+                isOwnList = true
+                return
+            }
+            isSubscribed = try await isSubscribed(toRid: mailingList.rid)
+        } catch {
+            // Leave state unknown; the toggle stays hidden rather than lying.
+        }
+    }
+
+    private func isSubscribed(toRid rid: String) async throws -> Bool {
+        var cursor: String?
+        repeat {
+            let response = try await client.execute(
+                service: .lists,
+                query: Self.subscriptionRidsQuery,
+                variables: cursor.map { ["cursor": $0] },
+                responseType: SubscriptionRidsResponse.self
+            )
+            if response.subscriptions.results.contains(where: { $0.list?.rid == rid }) {
+                return true
+            }
+            cursor = response.subscriptions.cursor
+        } while cursor != nil
+        return false
+    }
+
+    func toggleSubscription() async {
+        guard let id = listNumericID, let subscribed = isSubscribed, !isTogglingSubscription else { return }
+        isTogglingSubscription = true
+        defer { isTogglingSubscription = false }
+        do {
+            _ = try await client.execute(
+                service: .lists,
+                query: subscribed ? Self.unsubscribeMutation : Self.subscribeMutation,
+                variables: ["id": id],
+                responseType: SubscriptionToggleResponse.self
+            )
+            isSubscribed = !subscribed
+        } catch {
+            self.error = error.userFacingMessage
+        }
+    }
+
     var filteredThreads: [InboxThreadSummary] {
         Self.filterThreads(threads, matching: searchText)
     }
@@ -386,6 +513,21 @@ struct MailingListDetailView: View {
                     .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
                 }
             }
+            if let viewModel, !viewModel.isOwnList, let subscribed = viewModel.isSubscribed {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button {
+                        Task { await viewModel.toggleSubscription() }
+                    } label: {
+                        if viewModel.isTogglingSubscription {
+                            ProgressView().controlSize(.small)
+                        } else {
+                            Image(systemName: subscribed ? "bell.fill" : "bell")
+                        }
+                    }
+                    .disabled(viewModel.isTogglingSubscription)
+                    .accessibilityLabel(subscribed ? "Unsubscribe from list" : "Subscribe to list")
+                }
+            }
         }
         .task {
             if viewModel == nil {
@@ -397,6 +539,7 @@ struct MailingListDetailView: View {
                 )
                 self.viewModel = viewModel
                 await viewModel.loadThreads()
+                await viewModel.loadSubscriptionState(currentUserCanonicalName: currentUserKey)
             }
         }
         .onAppear {
diff --git a/ROADMAP.txt b/ROADMAP.txt
index cf171f8..5dbca20 100644
--- a/ROADMAP.txt
+++ b/ROADMAP.txt
@@ -27,6 +27,15 @@ Things the schema does not tell you, each of which has already cost real time.
   `git clone --depth 1 https://git.sr.ht/~sircmpwn/<service>.sr.ht` and look at
   `api/graph/schema.graphqls`. Regenerating the dumps with a full introspection
   query would remove the trap.
+- **`MailingList.subscription` does not report your subscription.** The field
+  exists and is typed `MailingListSubscription`, but it returns null even
+  immediately after a `mailingListSubscribe` that hands you back a real
+  subscription id — verified live against `~hutch`, for both owned and
+  non-owned lists. Do not gate subscribe-state on it. The authoritative source
+  is membership in the `subscriptions` query (correct: true after subscribe,
+  false after unsubscribe); the mutations take `listID: Int!`, read from
+  `list(rid:){ id }`. Cost the v3.11.0 subscribe toggle a full afternoon of the
+  "looks right, isn't" variety.
 
 ## Phase 0: Unblock CI — done (v3.5.0)
 
@@ -79,14 +88,14 @@ were never called. Each removes a "why can't I do this here?" moment.
 - ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and
   `copySelf`, surfaced as an Email section in Settings.
 
-`mailingListSubscribe` was left unwired here because `MailingList` had no
-`subscription` field, unlike `Ticket` and `Tracker`, so per-list state was only
-knowable from the `subscriptions` query — which by definition lists what the
-user is already subscribed to. **That has since changed:** live introspection
-during the ingest shows `MailingList.subscription` now exists (type
-`MailingListSubscription`, present when subscribed), so per-list state is
-readable and the `mailingListSubscribe` / `mailingListUnsubscribe` toggle can
-reflect it. Now buildable — see "mailing list subscribe" below.
+`mailingListSubscribe` was left unwired here on the view that per-list state was
+only knowable from the `subscriptions` query, and subscribing needs a list you
+are *not* subscribed to. **Shipped in v3.11.0** once live testing clarified two
+things: a specific list is reachable without a discovery API (Lookup, a project's
+lists, patchsets), and the `subscriptions` query *is* the reliable state source —
+membership in it answers "am I subscribed to this rid?". `MailingList.subscription`
+looked like a shortcut but is a trap (see API traps); it is not used. See
+"mailing list subscribe" below.
 
 ### Refactors folded in
 
@@ -146,7 +155,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*.
 | v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
 | 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 |
 | v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); ~~"What's cooking" ingest + doc truth-up~~ (done) | Ships one feature, corrects the map |
-| v3.11.0 | Mailing list subscribe/unsubscribe toggle | Ingest-surfaced; `MailingList.subscription` now exists |
+| v3.11.0 | ~~Mailing list subscribe/unsubscribe toggle~~ (shipped) | Ingest-surfaced; state via the `subscriptions` query (the `subscription` field is a trap) |
 | v3.12.0 | Accessibility | Independent, device-verified |
 | v4.0.0 | Localization *with* translations | The only true re-presentation |
 | — | 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
 and "standardized / connections-spec" GraphQL (Q2 named both as future work).
 The one *new* opening the introspection turned up is below.
 
-### Mailing list subscribe — buildable
-
-`MailingList` gained a `subscription` field (type `MailingListSubscription`,
-present when the viewer is subscribed) — confirmed live. That removes the exact
-blocker Phase 1 named: per-list state is now readable, so a subscribe /
-unsubscribe toggle can reflect real server state using the existing
-`mailingListSubscribe` / `mailingListUnsubscribe` mutations (unsubscribe already
-shipped in Phase 1). Surface it wherever a list is shown — Lookup results,
-`ProjectMailingListView`, the inbox list header. Small, self-contained write
-feature; its own minor.
+### Mailing list subscribe — done (v3.11.0)
+
+A subscribe / unsubscribe toggle now sits in the mailing-list detail toolbar
+(`MailingListDetailView`, which backs both Lookup results and
+`ProjectMailingListView`). It is hidden for lists you own and while state is
+unknown.
+
+Live testing rewrote the plan. `MailingList.subscription` looked like the state
+source but is a trap — it returns null even right after a successful
+`mailingListSubscribe` that hands back a subscription id (see API traps). So
+state comes from membership in the `subscriptions` query, which *is* reliable,
+and the numeric `listID` the mutations require comes from `list(rid:){ id }`.
+The mutations themselves (`mailingListSubscribe` / `mailingListUnsubscribe`,
+`listID: Int!`) work as expected. Phase 1's "no discovery API" worry was moot:
+a specific list is reachable via Lookup, a project's lists, or patchsets, and
+that is all subscribing needs.
 
 ### hub.sr.ht writes — projects and discovery done
 
diff --git a/SCOPE.txt b/SCOPE.txt
index 8300d7b..490ad45 100644
--- a/SCOPE.txt
+++ b/SCOPE.txt
@@ -31,10 +31,11 @@
   unaffected because it filters on `ev.ticket_id`, which is why ticket timelines
   work. Nothing a client can do fixes this; revisit only if sr.ht changes the
   resolver.
-- ~~Subscribe to a mailing list~~ — **now buildable**. `MailingList` gained a
-  `subscription` field (confirmed live during the Q1–Q2 2026 ingest), so per-list
-  subscribe state is readable and `mailingListSubscribe` / `mailingListUnsubscribe`
-  can drive a real toggle. Moved to ROADMAP.txt ("mailing list subscribe").
+- ~~Subscribe to a mailing list~~ — **shipped** (v3.11.0). A subscribe/unsubscribe
+  toggle on the mailing-list detail view. State comes from the `subscriptions`
+  query (`MailingList.subscription` is a trap — it never reports your state; see
+  ROADMAP.txt API traps), and a list is reachable to subscribe to via Lookup or a
+  project's lists — no discovery API needed.
 - Submitting patches (a `git send-email` flow, not a GraphQL mutation; Hutch
   reviews patchsets but cannot send them)