1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
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)"
}
}
}
|