diff options
Diffstat (limited to 'Hutch/Views/Settings')
| -rw-r--r-- | Hutch/Views/Settings/NotificationPreferencesViewModel.swift | 173 | ||||
| -rw-r--r-- | Hutch/Views/Settings/SettingsView.swift | 52 |
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 { |
