krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.8.0: Hutch/Views/Settings/NotificationPreferencesViewModel.swift · raw

  1import Foundation
  2
  3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
  4
  5private struct TodoPreferencesResponse: Decodable, Sendable {
  6    let preferences: TodoPreferences
  7}
  8
  9private struct TodoPreferences: Decodable, Sendable {
 10    let notifySelf: Bool
 11}
 12
 13private struct ListsPreferencesResponse: Decodable, Sendable {
 14    let preferences: ListsPreferences
 15}
 16
 17private struct ListsPreferences: Decodable, Sendable {
 18    let copySelf: Bool
 19}
 20
 21// MARK: - View Model
 22
 23/// Email preferences for todo.sr.ht and lists.sr.ht.
 24///
 25/// The two services each expose `preferences`/`updatePreferences` under the same
 26/// names but with different fields  `notifySelf` on todo, `copySelf` on lists 
 27/// and there is no shared preferences service, so both are handled side by side.
 28@Observable
 29@MainActor
 30final class NotificationPreferencesViewModel {
 31
 32    private(set) var notifySelf = false
 33    private(set) var copySelf = false
 34    private(set) var isLoading = false
 35    private(set) var isSavingNotifySelf = false
 36    private(set) var isSavingCopySelf = false
 37    private(set) var hasLoaded = false
 38    var error: String?
 39
 40    private let client: SRHTClient
 41
 42    init(client: SRHTClient) {
 43        self.client = client
 44    }
 45
 46    private static let todoPreferencesQuery = """
 47    query todoPreferences {
 48        preferences { notifySelf }
 49    }
 50    """
 51
 52    private static let listsPreferencesQuery = """
 53    query listsPreferences {
 54        preferences { copySelf }
 55    }
 56    """
 57
 58    private static let updateNotifySelfMutation = """
 59    mutation updateTodoPreferences($notifySelf: Boolean!) {
 60        preferences: updatePreferences(preferences: { notifySelf: $notifySelf }) {
 61            notifySelf
 62        }
 63    }
 64    """
 65
 66    private static let updateCopySelfMutation = """
 67    mutation updateListsPreferences($copySelf: Boolean!) {
 68        preferences: updatePreferences(preferences: { copySelf: $copySelf }) {
 69            copySelf
 70        }
 71    }
 72    """
 73
 74    func loadIfNeeded() async {
 75        guard !hasLoaded, !isLoading else { return }
 76        await load()
 77    }
 78
 79    func load() async {
 80        isLoading = true
 81        error = nil
 82        defer {
 83            isLoading = false
 84            hasLoaded = true
 85        }
 86
 87        // The two services are independent; one being unreachable should not hide
 88        // the other's setting.
 89        async let todo = fetchNotifySelf()
 90        async let lists = fetchCopySelf()
 91
 92        let (todoResult, listsResult) = await (todo, lists)
 93
 94        if let todoResult {
 95            notifySelf = todoResult
 96        }
 97        if let listsResult {
 98            copySelf = listsResult
 99        }
100
101        if todoResult == nil && listsResult == nil {
102            error = "Couldn't load your email preferences."
103        }
104    }
105
106    /// The fetches stay in their own methods so the response types are only ever
107    /// decoded on the main actor. The module defaults to MainActor isolation, so
108    /// decoding straight from an `async let` would use a main-actor-isolated
109    /// Decodable conformance from a nonisolated context.
110    private func fetchNotifySelf() async -> Bool? {
111        let response = try? await client.execute(
112            service: .todo,
113            query: Self.todoPreferencesQuery,
114            responseType: TodoPreferencesResponse.self
115        )
116        return response?.preferences.notifySelf
117    }
118
119    private func fetchCopySelf() async -> Bool? {
120        let response = try? await client.execute(
121            service: .lists,
122            query: Self.listsPreferencesQuery,
123            responseType: ListsPreferencesResponse.self
124        )
125        return response?.preferences.copySelf
126    }
127
128    func setNotifySelf(_ newValue: Bool) async {
129        guard !isSavingNotifySelf else { return }
130        isSavingNotifySelf = true
131        error = nil
132        defer { isSavingNotifySelf = false }
133
134        let previous = notifySelf
135        notifySelf = newValue
136
137        do {
138            let response = try await client.execute(
139                service: .todo,
140                query: Self.updateNotifySelfMutation,
141                variables: ["notifySelf": newValue],
142                responseType: TodoPreferencesResponse.self
143            )
144            notifySelf = response.preferences.notifySelf
145        } catch {
146            notifySelf = previous
147            self.error = "Couldn't update ticket email preference. \(error.userFacingMessage)"
148        }
149    }
150
151    func setCopySelf(_ newValue: Bool) async {
152        guard !isSavingCopySelf else { return }
153        isSavingCopySelf = true
154        error = nil
155        defer { isSavingCopySelf = false }
156
157        let previous = copySelf
158        copySelf = newValue
159
160        do {
161            let response = try await client.execute(
162                service: .lists,
163                query: Self.updateCopySelfMutation,
164                variables: ["copySelf": newValue],
165                responseType: ListsPreferencesResponse.self
166            )
167            copySelf = response.preferences.copySelf
168        } catch {
169            copySelf = previous
170            self.error = "Couldn't update mailing list email preference. \(error.userFacingMessage)"
171        }
172    }
173}