diff options
| author | Christian Cleberg <[email protected]> | 2026-08-07 16:57:37 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-08-07 16:57:37 -0500 |
| commit | 9d85bc7c154843693913bf0cc67c6e9ff0d8f897 (patch) | |
| tree | f53cbbe9e0644e08f534f2f317c18ccfcefd3c99 | |
| parent | 44693c2977b7301ec04eae287cf24ff0226bc92e (diff) | |
| parent | 039f06095cb3df072968be2f19f45bace605baeb (diff) | |
| download | hutch-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.pbxproj | 24 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectMailingListView.swift | 143 | ||||
| -rw-r--r-- | ROADMAP.txt | 53 | ||||
| -rw-r--r-- | 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 @@ -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) |
