summaryrefslogtreecommitdiff
path: root/Hutch/Views/Settings
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Settings')
-rw-r--r--Hutch/Views/Settings/NotificationPreferencesViewModel.swift173
-rw-r--r--Hutch/Views/Settings/SettingsView.swift52
2 files changed, 225 insertions, 0 deletions
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 {