summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Lists/MailingListListView.swift67
-rw-r--r--Hutch/Views/Settings/NotificationPreferencesViewModel.swift173
-rw-r--r--Hutch/Views/Settings/SettingsView.swift52
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift133
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift173
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift15
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift88
7 files changed, 701 insertions, 0 deletions
diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift
index 2fcc640..09fa2a0 100644
--- a/Hutch/Views/Lists/MailingListListView.swift
+++ b/Hutch/Views/Lists/MailingListListView.swift
@@ -5,6 +5,7 @@ import SwiftUI
final class MailingListListViewModel {
private(set) var mailingLists: [InboxMailingListReference] = []
private(set) var isLoading = false
+ private(set) var isPerformingAction = false
var error: String?
var searchText = ""
@@ -28,10 +29,51 @@ final class MailingListListViewModel {
}
"""
+ private static let unsubscribeMutation = """
+ mutation mailingListUnsubscribe($listID: Int!) {
+ subscription: mailingListUnsubscribe(listID: $listID) { id }
+ }
+ """
+
init(client: SRHTClient) {
self.client = client
}
+ /// Unsubscribes from a list and drops it from the list on success. This view
+ /// is built from the subscriptions query, so a successful unsubscribe means
+ /// the row no longer belongs here.
+ func unsubscribe(from mailingList: InboxMailingListReference) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ let previousLists = mailingLists
+ mailingLists.removeAll { $0.rid == mailingList.rid }
+
+ do {
+ struct Response: Decodable, Sendable {
+ // mailingListUnsubscribe is nullable: sr.ht returns null when there
+ // was no subscription to remove, which is still a success.
+ let subscription: SubscriptionPayload?
+ }
+
+ struct SubscriptionPayload: Decodable, Sendable {
+ let id: Int
+ }
+
+ _ = try await client.execute(
+ service: .lists,
+ query: Self.unsubscribeMutation,
+ variables: ["listID": mailingList.id],
+ responseType: Response.self
+ )
+ } catch {
+ mailingLists = previousLists
+ self.error = "Couldn't unsubscribe from \(mailingList.name). \(error.userFacingMessage)"
+ }
+ }
+
var filteredMailingLists: [InboxMailingListReference] {
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !q.isEmpty else { return mailingLists }
@@ -106,6 +148,7 @@ final class MailingListListViewModel {
struct MailingListListView: View {
@Environment(AppState.self) private var appState
@State private var viewModel: MailingListListViewModel?
+ @State private var pendingUnsubscribe: InboxMailingListReference?
var body: some View {
Group {
@@ -141,6 +184,14 @@ struct MailingListListView: View {
}
.padding(.vertical, 2)
}
+ .swipeActions(edge: .trailing) {
+ Button {
+ pendingUnsubscribe = mailingList
+ } label: {
+ SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
+ }
+ .tint(.orange)
+ }
}
.themedRow()
}
@@ -151,6 +202,22 @@ struct MailingListListView: View {
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search lists"
)
+ .confirmationDialog(
+ pendingUnsubscribe.map { "Unsubscribe from \($0.name)?" } ?? "",
+ isPresented: .init(
+ get: { pendingUnsubscribe != nil },
+ set: { if !$0 { pendingUnsubscribe = nil } }
+ ),
+ titleVisibility: .visible,
+ presenting: pendingUnsubscribe
+ ) { mailingList in
+ Button("Unsubscribe", role: .destructive) {
+ Task { await viewModel.unsubscribe(from: mailingList) }
+ }
+ Button("Cancel", role: .cancel) { pendingUnsubscribe = nil }
+ } message: { _ in
+ Text("You will stop receiving email from this list. Hutch cannot resubscribe you — you would need to do that from the list's page on the web.")
+ }
.overlay {
if viewModel.isLoading, viewModel.mailingLists.isEmpty {
SRHTLoadingStateView(message: "Loading mailing lists…")
diff --git a/Hutch/Views/Settings/NotificationPreferencesViewModel.swift b/Hutch/Views/Settings/NotificationPreferencesViewModel.swift
new file mode 100644
index 0000000..dc60eae
--- /dev/null
+++ b/Hutch/Views/Settings/NotificationPreferencesViewModel.swift
@@ -0,0 +1,173 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TodoPreferencesResponse: Decodable, Sendable {
+ let preferences: TodoPreferences
+}
+
+private struct TodoPreferences: Decodable, Sendable {
+ let notifySelf: Bool
+}
+
+private struct ListsPreferencesResponse: Decodable, Sendable {
+ let preferences: ListsPreferences
+}
+
+private struct ListsPreferences: Decodable, Sendable {
+ let copySelf: Bool
+}
+
+// MARK: - View Model
+
+/// Email preferences for todo.sr.ht and lists.sr.ht.
+///
+/// The two services each expose `preferences`/`updatePreferences` under the same
+/// names but with different fields — `notifySelf` on todo, `copySelf` on lists —
+/// and there is no shared preferences service, so both are handled side by side.
+@Observable
+@MainActor
+final class NotificationPreferencesViewModel {
+
+ private(set) var notifySelf = false
+ private(set) var copySelf = false
+ private(set) var isLoading = false
+ private(set) var isSavingNotifySelf = false
+ private(set) var isSavingCopySelf = false
+ private(set) var hasLoaded = false
+ var error: String?
+
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ private static let todoPreferencesQuery = """
+ query todoPreferences {
+ preferences { notifySelf }
+ }
+ """
+
+ private static let listsPreferencesQuery = """
+ query listsPreferences {
+ preferences { copySelf }
+ }
+ """
+
+ private static let updateNotifySelfMutation = """
+ mutation updateTodoPreferences($notifySelf: Boolean!) {
+ preferences: updatePreferences(preferences: { notifySelf: $notifySelf }) {
+ notifySelf
+ }
+ }
+ """
+
+ private static let updateCopySelfMutation = """
+ mutation updateListsPreferences($copySelf: Boolean!) {
+ preferences: updatePreferences(preferences: { copySelf: $copySelf }) {
+ copySelf
+ }
+ }
+ """
+
+ func loadIfNeeded() async {
+ guard !hasLoaded, !isLoading else { return }
+ await load()
+ }
+
+ func load() async {
+ isLoading = true
+ error = nil
+ defer {
+ isLoading = false
+ hasLoaded = true
+ }
+
+ // The two services are independent; one being unreachable should not hide
+ // the other's setting.
+ async let todo = fetchNotifySelf()
+ async let lists = fetchCopySelf()
+
+ let (todoResult, listsResult) = await (todo, lists)
+
+ if let todoResult {
+ notifySelf = todoResult
+ }
+ if let listsResult {
+ copySelf = listsResult
+ }
+
+ if todoResult == nil && listsResult == nil {
+ error = "Couldn't load your email preferences."
+ }
+ }
+
+ /// The fetches stay in their own methods so the response types are only ever
+ /// decoded on the main actor. The module defaults to MainActor isolation, so
+ /// decoding straight from an `async let` would use a main-actor-isolated
+ /// Decodable conformance from a nonisolated context.
+ private func fetchNotifySelf() async -> Bool? {
+ let response = try? await client.execute(
+ service: .todo,
+ query: Self.todoPreferencesQuery,
+ responseType: TodoPreferencesResponse.self
+ )
+ return response?.preferences.notifySelf
+ }
+
+ private func fetchCopySelf() async -> Bool? {
+ let response = try? await client.execute(
+ service: .lists,
+ query: Self.listsPreferencesQuery,
+ responseType: ListsPreferencesResponse.self
+ )
+ return response?.preferences.copySelf
+ }
+
+ func setNotifySelf(_ newValue: Bool) async {
+ guard !isSavingNotifySelf else { return }
+ isSavingNotifySelf = true
+ error = nil
+ defer { isSavingNotifySelf = false }
+
+ let previous = notifySelf
+ notifySelf = newValue
+
+ do {
+ let response = try await client.execute(
+ service: .todo,
+ query: Self.updateNotifySelfMutation,
+ variables: ["notifySelf": newValue],
+ responseType: TodoPreferencesResponse.self
+ )
+ notifySelf = response.preferences.notifySelf
+ } catch {
+ notifySelf = previous
+ self.error = "Couldn't update ticket email preference. \(error.userFacingMessage)"
+ }
+ }
+
+ func setCopySelf(_ newValue: Bool) async {
+ guard !isSavingCopySelf else { return }
+ isSavingCopySelf = true
+ error = nil
+ defer { isSavingCopySelf = false }
+
+ let previous = copySelf
+ copySelf = newValue
+
+ do {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.updateCopySelfMutation,
+ variables: ["copySelf": newValue],
+ responseType: ListsPreferencesResponse.self
+ )
+ copySelf = response.preferences.copySelf
+ } catch {
+ copySelf = previous
+ self.error = "Couldn't update mailing list email preference. \(error.userFacingMessage)"
+ }
+ }
+}
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 0dd4319..8d6bd87 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -10,16 +10,23 @@ struct SettingsView: View {
private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
@State private var pendingDestructiveAction: SettingsDestructiveAction?
@State private var showAccountSwitcher = false
+ @State private var preferences: NotificationPreferencesViewModel?
var body: some View {
Form {
appearanceSection()
behaviorSection()
+ emailSection()
safariExtensionSection()
authenticationSection()
}
.themedList()
.navigationTitle("Settings")
+ .task {
+ let viewModel = preferences ?? NotificationPreferencesViewModel(client: appState.client)
+ preferences = viewModel
+ await viewModel.loadIfNeeded()
+ }
.sheet(isPresented: $showAccountSwitcher) {
AccountSwitcherView()
}
@@ -118,6 +125,51 @@ struct SettingsView: View {
}
@ViewBuilder
+ private func emailSection() -> some View {
+ Section {
+ if let preferences {
+ Toggle(
+ "Notify me about my own tickets",
+ isOn: Binding(
+ get: { preferences.notifySelf },
+ set: { newValue in
+ Task { await preferences.setNotifySelf(newValue) }
+ }
+ )
+ )
+ .disabled(preferences.isLoading || preferences.isSavingNotifySelf)
+ .themedRow()
+
+ Toggle(
+ "Copy me on my own list mail",
+ isOn: Binding(
+ get: { preferences.copySelf },
+ set: { newValue in
+ Task { await preferences.setCopySelf(newValue) }
+ }
+ )
+ )
+ .disabled(preferences.isLoading || preferences.isSavingCopySelf)
+ .themedRow()
+
+ if let error = preferences.error {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .themedRow()
+ }
+ } else {
+ ProgressView()
+ .themedRow()
+ }
+ } header: {
+ Text("Email")
+ } footer: {
+ Text("These are stored on SourceHut and apply everywhere, not just in Hutch. The first controls whether todo.sr.ht emails you about your own ticket activity; the second whether lists.sr.ht copies you on mail you send to a list.")
+ }
+ }
+
+ @ViewBuilder
private func authenticationSection() -> some View {
Section {
HStack {
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index 92263f6..b114fa6 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -11,12 +11,15 @@ struct TicketDetailView: View {
@Environment(AppState.self) private var appState
@Environment(\.colorScheme) private var colorScheme
@Environment(\.openURL) private var openURL
+ @Environment(\.dismiss) private var dismiss
@State private var viewModel: TicketDetailViewModel?
// Sheet state
@State private var showResolveSheet = false
@State private var showAssignSheet = false
@State private var showLabelsSheet = false
+ @State private var showEditSheet = false
+ @State private var showDeleteConfirmation = false
@State private var isOpeningTracker = false
// Comment composer mode
@@ -100,9 +103,30 @@ struct TicketDetailView: View {
SwiftUI.Label("Copy Tracker RID", systemImage: "number")
}
+ Divider()
+
+ Button {
+ Task { await viewModel.toggleSubscription() }
+ } label: {
+ if viewModel.isSubscribed {
+ SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
+ } else {
+ SwiftUI.Label("Subscribe", systemImage: "bell")
+ }
+ }
+ .disabled(viewModel.isPerformingAction)
+
if isOwnedByCurrentUser {
Divider()
+ if viewModel.ticket != nil {
+ Button {
+ showEditSheet = true
+ } label: {
+ SwiftUI.Label("Edit Ticket", systemImage: "square.and.pencil")
+ }
+ }
+
if let ticket = viewModel.ticket {
if ticket.status == .resolved {
Button {
@@ -133,6 +157,14 @@ struct TicketDetailView: View {
} label: {
SwiftUI.Label("Manage Labels", systemImage: "tag")
}
+
+ Divider()
+
+ Button(role: .destructive) {
+ showDeleteConfirmation = true
+ } label: {
+ SwiftUI.Label("Delete Ticket", systemImage: "trash")
+ }
}
} label: {
Image(systemName: "ellipsis.circle")
@@ -150,6 +182,32 @@ struct TicketDetailView: View {
LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
.presentationDetents([.medium])
}
+ .sheet(isPresented: $showEditSheet) {
+ if let ticket = viewModel.ticket {
+ EditTicketSheet(
+ viewModel: viewModel,
+ isPresented: $showEditSheet,
+ initialSubject: ticket.title,
+ initialBody: ticket.description ?? ""
+ )
+ }
+ }
+ .confirmationDialog(
+ "Delete Ticket #\(ticketId)?",
+ isPresented: $showDeleteConfirmation,
+ titleVisibility: .visible
+ ) {
+ Button("Delete Ticket", role: .destructive) {
+ Task {
+ if await viewModel.deleteTicket() {
+ dismiss()
+ }
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("This permanently deletes the ticket and its comments. This cannot be undone.")
+ }
}
// MARK: - Detail Content
@@ -724,6 +782,81 @@ private struct EventRow: View {
// MARK: - Resolve Sheet
+private struct EditTicketSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ let initialSubject: String
+ let initialBody: String
+
+ @State private var subject: String
+ @State private var ticketBody: String
+
+ init(
+ viewModel: TicketDetailViewModel,
+ isPresented: Binding<Bool>,
+ initialSubject: String,
+ initialBody: String
+ ) {
+ self.viewModel = viewModel
+ _isPresented = isPresented
+ self.initialSubject = initialSubject
+ self.initialBody = initialBody
+ _subject = State(initialValue: initialSubject)
+ _ticketBody = State(initialValue: initialBody)
+ }
+
+ private var trimmedSubject: String {
+ subject.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private var hasChanges: Bool {
+ trimmedSubject != initialSubject
+ || ticketBody.trimmingCharacters(in: .whitespacesAndNewlines) != initialBody
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Subject") {
+ TextField("Subject", text: $subject, axis: .vertical)
+ .themedRow()
+ }
+
+ Section("Description") {
+ TextField("Description", text: $ticketBody, axis: .vertical)
+ .lineLimit(5...15)
+ .themedRow()
+ }
+ }
+ .themedList()
+ .navigationTitle("Edit Ticket")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { isPresented = false }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ Task {
+ if await viewModel.updateTicket(subject: subject, body: ticketBody) {
+ isPresented = false
+ }
+ }
+ }
+ .disabled(viewModel.isPerformingAction || trimmedSubject.isEmpty || !hasChanges)
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Resolve Sheet
+
private struct ResolveSheet: View {
let viewModel: TicketDetailViewModel
@Binding var isPresented: Bool
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
index df2fd46..714f03a 100644
--- a/Hutch/Views/Tickets/TicketDetailViewModel.swift
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -19,12 +19,18 @@ private struct TicketDetailPayload: Decodable, Sendable {
let status: TicketStatus
let resolution: TicketResolution?
let authenticity: Authenticity
+ /// Null when the authenticated user is not subscribed to this ticket.
+ let subscription: SubscriptionIdPayload?
let submitter: Entity
let assignees: [Entity]
let labels: [TicketLabel]
let events: EventsPage
}
+struct SubscriptionIdPayload: Decodable, Sendable {
+ let id: Int
+}
+
private struct EventsPage: Decodable, Sendable {
let results: [TicketEvent]
let cursor: String?
@@ -52,6 +58,22 @@ private struct UpdatedStatusEvent: Decodable, Sendable {
let eventType: String
}
+private struct TicketSubscriptionResponse: Decodable, Sendable {
+ let subscription: SubscriptionIdPayload
+}
+
+private struct UpdateTicketResponse: Decodable, Sendable {
+ let updateTicket: TicketIdPayload
+}
+
+private struct DeleteTicketResponse: Decodable, Sendable {
+ let deleteTicket: TicketIdPayload
+}
+
+private struct TicketIdPayload: Decodable, Sendable {
+ let id: Int
+}
+
private struct AssignUserResponse: Decodable, Sendable {
let assignUser: MutationEventResponse
}
@@ -112,6 +134,9 @@ final class TicketDetailViewModel {
private(set) var isLoading = false
private(set) var isSubmitting = false
private(set) var isPerformingAction = false
+ /// Whether the authenticated user receives email for this ticket. Mirrors
+ /// `Ticket.subscription`, which is null when not subscribed.
+ private(set) var isSubscribed = false
private(set) var trackerLabels: [TicketLabel] = []
private(set) var rawTicketResponse: String?
private(set) var cacheMetadata: CacheEntryMetadata?
@@ -141,6 +166,35 @@ final class TicketDetailViewModel {
return input
}
+ /// Builds an `UpdateTicketInput` carrying only the fields that changed, so an
+ /// edit never overwrites a field the user did not touch.
+ static func ticketUpdateInput(
+ subject: String,
+ body: String,
+ currentSubject: String,
+ currentBody: String?
+ ) -> [String: any Sendable] {
+ var input: [String: any Sendable] = [:]
+ let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if trimmedSubject != currentSubject {
+ input["subject"] = trimmedSubject
+ }
+
+ if trimmedBody != (currentBody ?? "") {
+ if trimmedBody.isEmpty {
+ // A nil subscript assignment would drop the key and leave the old
+ // body in place instead of clearing it.
+ input.updateValue(Optional<String>.none as any Sendable, forKey: "body")
+ } else {
+ input["body"] = trimmedBody
+ }
+ }
+
+ return input
+ }
+
init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
self.ownerUsername = ownerUsername
self.trackerName = trackerName
@@ -164,6 +218,7 @@ final class TicketDetailViewModel {
status
resolution
authenticity
+ subscription { id }
submitter { canonicalName }
assignees { canonicalName }
labels { id name backgroundColor foregroundColor }
@@ -233,6 +288,30 @@ final class TicketDetailViewModel {
}
"""
+ private static let updateTicketMutation = """
+ mutation updateTicket($trackerId: Int!, $ticketId: Int!, $input: UpdateTicketInput!) {
+ updateTicket(trackerId: $trackerId, ticketId: $ticketId, input: $input) { id }
+ }
+ """
+
+ private static let deleteTicketMutation = """
+ mutation deleteTicket($trackerId: Int!, $ticketId: Int!) {
+ deleteTicket(trackerId: $trackerId, ticketId: $ticketId) { id }
+ }
+ """
+
+ private static let ticketSubscribeMutation = """
+ mutation ticketSubscribe($trackerId: Int!, $ticketId: Int!) {
+ subscription: ticketSubscribe(trackerId: $trackerId, ticketId: $ticketId) { id }
+ }
+ """
+
+ private static let ticketUnsubscribeMutation = """
+ mutation ticketUnsubscribe($trackerId: Int!, $ticketId: Int!) {
+ subscription: ticketUnsubscribe(trackerId: $trackerId, ticketId: $ticketId) { id }
+ }
+ """
+
private static let assignUserMutation = """
mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
@@ -420,6 +499,99 @@ final class TicketDetailViewModel {
isPerformingAction = false
}
+ /// Edits the ticket's subject and body. Returns true when the edit was sent,
+ /// including the no-op case where nothing changed.
+ @discardableResult
+ func updateTicket(subject: String, body: String) async -> Bool {
+ guard !isPerformingAction, let ticket else { return false }
+
+ let input = Self.ticketUpdateInput(
+ subject: subject,
+ body: body,
+ currentSubject: ticket.title,
+ currentBody: ticket.description
+ )
+ guard !input.isEmpty else { return true }
+
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.updateTicketMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "input": input
+ ],
+ responseType: UpdateTicketResponse.self
+ )
+ await invalidateAfterMutation()
+ await reloadTicketPreservingDebugState()
+ return true
+ } catch {
+ self.error = error.userFacingMessage
+ return false
+ }
+ }
+
+ /// Subscribes to or unsubscribes from email notifications for this ticket.
+ func toggleSubscription() async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ let wasSubscribed = isSubscribed
+ // Reflect the change immediately; the catch below puts it back if the
+ // mutation fails, so the control never lies about server state.
+ isSubscribed.toggle()
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: wasSubscribed ? Self.ticketUnsubscribeMutation : Self.ticketSubscribeMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId
+ ],
+ responseType: TicketSubscriptionResponse.self
+ )
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
+ } catch {
+ isSubscribed = wasSubscribed
+ self.error = error.userFacingMessage
+ }
+ }
+
+ /// Deletes the ticket. Returns true on success so the caller can pop the view.
+ @discardableResult
+ func deleteTicket() async -> Bool {
+ guard !isPerformingAction else { return false }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.deleteTicketMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId
+ ],
+ responseType: DeleteTicketResponse.self
+ )
+ await invalidateAfterMutation()
+ return true
+ } catch {
+ self.error = error.userFacingMessage
+ return false
+ }
+ }
+
func assignUser(username: String) async {
guard !isPerformingAction else { return }
isPerformingAction = true
@@ -691,6 +863,7 @@ final class TicketDetailViewModel {
labels: payload.labels
)
ticket = updatedTicket
+ isSubscribed = payload.subscription != nil
let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder)
events = updatedEvents
}
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index afee96d..650554c 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -242,6 +242,7 @@ struct TicketListView: View {
trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
await vm.loadTickets()
await vm.loadTrackerLabels()
+ await vm.loadSubscriptionState()
}
}
}
@@ -469,6 +470,20 @@ struct TicketListView: View {
Divider()
+ if let viewModel {
+ Button {
+ Task { await viewModel.toggleSubscription() }
+ } label: {
+ Label(
+ viewModel.isSubscribed ? "Unsubscribe" : "Subscribe",
+ systemImage: viewModel.isSubscribed ? "bell.slash" : "bell"
+ )
+ }
+ .disabled(viewModel.isPerformingAction)
+ }
+
+ Divider()
+
if let trackerURL = SRHTWebURL.tracker(tracker) {
Button {
openURL(trackerURL)
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index 1f44993..2727dcc 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -33,6 +33,23 @@ private struct LabelMutationResponse: Decodable, Sendable {
let unlabelTicket: EventRef?
}
+private struct TrackerSubscriptionStateResponse: Decodable, Sendable {
+ let tracker: TrackerSubscriptionWrapper
+}
+
+private struct TrackerSubscriptionWrapper: Decodable, Sendable {
+ /// Null when the authenticated user is not subscribed to this tracker.
+ let subscription: TrackerSubscriptionIdPayload?
+}
+
+private struct TrackerSubscriptionResponse: Decodable, Sendable {
+ let subscription: TrackerSubscriptionIdPayload
+}
+
+private struct TrackerSubscriptionIdPayload: Decodable, Sendable {
+ let id: Int
+}
+
private struct TrackerLabelsResponse: Decodable, Sendable {
let tracker: TrackerLabelsWrapper
}
@@ -90,6 +107,9 @@ final class TicketListViewModel {
private(set) var isLoadingMore = false
private(set) var isCreatingTicket = false
private(set) var isPerformingAction = false
+ /// Whether the authenticated user receives email for this tracker. Mirrors
+ /// `Tracker.subscription`, which is null when not subscribed.
+ private(set) var isSubscribed = false
private(set) var trackerLabels: [TicketLabel] = []
private(set) var recentSearches: [ScopedSearchHistoryEntry]
private(set) var savedFilters: [SavedTicketFilter]
@@ -216,6 +236,28 @@ final class TicketListViewModel {
}
"""
+ /// Kept separate from `query` above, which is paginated and cached — the
+ /// subscription is per-user state and should not ride along in page payloads.
+ private static let trackerSubscriptionQuery = """
+ query trackerSubscription($rid: ID!) {
+ tracker(rid: $rid) {
+ subscription { id }
+ }
+ }
+ """
+
+ private static let trackerSubscribeMutation = """
+ mutation trackerSubscribe($trackerId: Int!) {
+ subscription: trackerSubscribe(trackerId: $trackerId) { id }
+ }
+ """
+
+ private static let trackerUnsubscribeMutation = """
+ mutation trackerUnsubscribe($trackerId: Int!, $tickets: Boolean!) {
+ subscription: trackerUnsubscribe(trackerId: $trackerId, tickets: $tickets) { id }
+ }
+ """
+
private static let trackerLabelsQuery = """
query trackerLabels($rid: ID!) {
tracker(rid: $rid) {
@@ -520,6 +562,52 @@ final class TicketListViewModel {
isPerformingAction = false
}
+ /// Reads whether the user is subscribed to this tracker. Uncached: it is
+ /// per-user state that must be accurate the moment the menu opens.
+ func loadSubscriptionState() async {
+ do {
+ let response = try await client.execute(
+ service: .todo,
+ query: Self.trackerSubscriptionQuery,
+ variables: ["rid": trackerRid],
+ responseType: TrackerSubscriptionStateResponse.self
+ )
+ isSubscribed = response.tracker.subscription != nil
+ } catch {
+ // Leave the last known value alone; the toggle reports its own errors.
+ }
+ }
+
+ /// Subscribes to or unsubscribes from email notifications for this tracker.
+ /// Unsubscribing leaves individual ticket subscriptions intact.
+ func toggleSubscription() async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ let wasSubscribed = isSubscribed
+ isSubscribed.toggle()
+
+ var variables: [String: any Sendable] = ["trackerId": trackerId]
+ if wasSubscribed {
+ variables["tickets"] = false
+ }
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: wasSubscribed ? Self.trackerUnsubscribeMutation : Self.trackerSubscribeMutation,
+ variables: variables,
+ responseType: TrackerSubscriptionResponse.self
+ )
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
+ } catch {
+ isSubscribed = wasSubscribed
+ self.error = error.userFacingMessage
+ }
+ }
+
func loadTrackerLabels() async {
do {
let cached = try await client.executeCached(