From 039f06095cb3df072968be2f19f45bace605baeb Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 7 Aug 2026 16:51:32 -0500 Subject: Mailing list subscribe/unsubscribe toggle (v3.11.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/Views/Projects/ProjectMailingListView.swift | 143 ++++++++++++++++++++++ 1 file changed, 143 insertions(+) (limited to 'Hutch/Views/Projects') 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 { -- cgit v1.2.3