summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-08-07 16:57:37 -0500
committerGitHub <[email protected]>2026-08-07 16:57:37 -0500
commit9d85bc7c154843693913bf0cc67c6e9ff0d8f897 (patch)
treef53cbbe9e0644e08f534f2f317c18ccfcefd3c99
parent44693c2977b7301ec04eae287cf24ff0226bc92e (diff)
parent039f06095cb3df072968be2f19f45bace605baeb (diff)
downloadhutch-3.11.0.tar.gz
hutch-3.11.0.tar.bz2
hutch-3.11.0.zip
Merge pull request #38 from krazywarez/mailing-list-subscribev3.11.0
Mailing list subscribe/unsubscribe toggle (v3.11.0)
-rw-r--r--Hutch.xcodeproj/project.pbxproj24
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift143
-rw-r--r--ROADMAP.txt53
-rw-r--r--SCOPE.txt9
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)