summaryrefslogtreecommitdiff
path: root/Hutch/Views
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 /Hutch/Views
parent44693c2977b7301ec04eae287cf24ff0226bc92e (diff)
parent039f06095cb3df072968be2f19f45bace605baeb (diff)
downloadhutch-9d85bc7c154843693913bf0cc67c6e9ff0d8f897.tar.gz
hutch-9d85bc7c154843693913bf0cc67c6e9ff0d8f897.tar.bz2
hutch-9d85bc7c154843693913bf0cc67c6e9ff0d8f897.zip
Merge pull request #38 from krazywarez/mailing-list-subscribev3.11.0
Mailing list subscribe/unsubscribe toggle (v3.11.0)
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift143
1 files changed, 143 insertions, 0 deletions
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 {