Commit aa1e2d22c6

aa1e2d22c6b9c16b338c90983577510755a4f949

parent: 21e79f09ba

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 23:25 UTC

push: re-read authorization on activation, stop reloading the inbox

Two fixes from review. The denied row went stale after the Settings
round trip it exists to send the user on: nothing re-read iOS
authorization on return, so a user who just granted permission still
saw the disabled toggle and the turned-off notice until they left the
screen and came back. Re-read it on scene activation and on pull to
refresh.

Flipping the toggle or removing a device also reused perform(), whose
reload is the inbox list — that fetch set the list to .loading and
blanked every row behind a spinner for a write the user was watching.
Split perform() into the shared write-and-error-handling and the
inbox reload it layers on for markRead/markAllRead; the push callers
use the write alone and re-read only their own state afterward. Also
guarded setPush/removeDevice against a fast double-tap, matching
markRead's existing guard.

Ref krz/gitbay#89

Layout: unified · split

gitbay/Account/NotificationsViewModel.swift +24 −3
@@ -134,13 +134,19 @@ final class NotificationsViewModel {
134 ["notifications", "device", "list"], of: PushDevice.self)) ?? [] 134 ["notifications", "device", "list"], of: PushDevice.self)) ?? []
135 } 135 }
136 136
137 /// Unlike `markRead`/`markAllRead`, this does not reload the inbox —
138 /// only its own state, so the list on screen does not blank out
139 /// behind a spinner for an unrelated fetch.
137 func setPush(_ on: Bool) async { 140 func setPush(_ on: Bool) async {
138 await perform(["notifications", "settings", "push", on ? "on" : "off"]) 141 guard !working else { return }
142 await performWrite(["notifications", "settings", "push", on ? "on" : "off"])
139 await loadPushSettings() 143 await loadPushSettings()
140 } 144 }
141 145
146 /// See `setPush` — no inbox reload here either.
142 func removeDevice(_ id: Int64) async { 147 func removeDevice(_ id: Int64) async {
143 await perform(["notifications", "device", "remove", String(id)]) 148 guard !working else { return }
149 await performWrite(["notifications", "device", "remove", String(id)])
144 await loadPushSettings() 150 await loadPushSettings()
145 } 151 }
146 152
@@ -148,17 +154,32 @@ final class NotificationsViewModel {
148 list.argv = ["notifications", "list"] + (showAll ? ["--all"] : []) 154 list.argv = ["notifications", "list"] + (showAll ? ["--all"] : [])
149 } 155 }
150 156
157 /// `markRead`/`markAllRead`'s write: the shared error handling, plus
158 /// the inbox reload that makes sense for them but not for the push
159 /// settings below.
151 private func perform(_ argv: [String]) async { 160 private func perform(_ argv: [String]) async {
161 if await performWrite(argv) {
162 await load()
163 }
164 }
165
166 /// The write and its error handling alone, shared by every caller in
167 /// this file. Returns whether it succeeded, so a caller can decide
168 /// what to reload.
169 @discardableResult
170 private func performWrite(_ argv: [String]) async -> Bool {
152 working = true 171 working = true
153 actionError = nil 172 actionError = nil
154 defer { working = false } 173 defer { working = false }
155 do { 174 do {
156 try await client.run(argv) 175 try await client.run(argv)
157 await load() 176 return true
158 } catch let error as GitbayError { 177 } catch let error as GitbayError {
159 actionError = error.userFacingMessage 178 actionError = error.userFacingMessage
179 return false
160 } catch { 180 } catch {
161 actionError = GitbayError.transport(error).userFacingMessage 181 actionError = GitbayError.transport(error).userFacingMessage
182 return false
162 } 183 }
163 } 184 }
164} 185}
gitbay/Views/Account/NotificationsView.swift +10
@@ -8,6 +8,7 @@ struct NotificationsView: View {
8 8
9 @State private var model: NotificationsViewModel 9 @State private var model: NotificationsViewModel
10 @Environment(PushRegistrar.self) private var registrar 10 @Environment(PushRegistrar.self) private var registrar
11 @Environment(\.scenePhase) private var scenePhase
11 @State private var authorization: UNAuthorizationStatus = .notDetermined 12 @State private var authorization: UNAuthorizationStatus = .notDetermined
12 13
13 init(client: GitbayClient) { 14 init(client: GitbayClient) {
@@ -57,9 +58,18 @@ struct NotificationsView: View {
57 .task { await model.load() } 58 .task { await model.load() }
58 .task { await model.loadPushSettings() } 59 .task { await model.loadPushSettings() }
59 .task { authorization = await registrar.authorizationStatus() } 60 .task { authorization = await registrar.authorizationStatus() }
61 // Settings is exactly where the denied row sends the user, and
62 // returning from it is a scene activation, not a refresh — this
63 // is what actually clears a stale "denied" once they flip the
64 // switch there.
65 .onChange(of: scenePhase) { _, newPhase in
66 guard newPhase == .active else { return }
67 Task { authorization = await registrar.authorizationStatus() }
68 }
60 .refreshable { 69 .refreshable {
61 await model.load() 70 await model.load()
62 await model.loadPushSettings() 71 await model.loadPushSettings()
72 authorization = await registrar.authorizationStatus()
63 } 73 }
64 } 74 }
65 75
gitbayTests/NotificationTests.swift +31 −4
@@ -258,39 +258,66 @@ struct NotificationsViewModelTests {
258 #expect(model.devices.isEmpty) 258 #expect(model.devices.isEmpty)
259 } 259 }
260 260
261 @Test func settingPushSendsOnAndReloadsTheState() async throws { 261 /// Flipping the toggle must not touch the inbox: three requests
262 /// total (the write, then the two reads `loadPushSettings` makes for
263 /// its own state), never a fourth for `notifications list`. That
264 /// count is what stops the notification list on screen from
265 /// blanking out behind a spinner for an unrelated fetch.
266 @Test func settingPushReloadsItsOwnStateOnlyNotTheInbox() async throws {
262 let (client, stub) = try makeClient() 267 let (client, stub) = try makeClient()
263 let model = NotificationsViewModel(client: client) 268 let model = NotificationsViewModel(client: client)
264 269
265 stub.enqueue(.init(status: 200, json: """ 270 stub.enqueue(.init(status: 200, json: """
266 {"protocol_version":1,"exit_code":0} 271 {"protocol_version":1,"exit_code":0}
267 """)) 272 """))
268 stub.enqueue(.init(status: 200, json: page))
269 stub.enqueue(.init(status: 200, json: settingsPushOn)) 273 stub.enqueue(.init(status: 200, json: settingsPushOn))
270 stub.enqueue(.init(status: 200, json: noDevices)) 274 stub.enqueue(.init(status: 200, json: noDevices))
271 await model.setPush(true) 275 await model.setPush(true)
272 276
277 #expect(stub.seen.count == 3)
273 let write = try #require(stub.seen.first { $0.method == "POST" }) 278 let write = try #require(stub.seen.first { $0.method == "POST" })
274 #expect(try argvOf(write) == ["notifications", "settings", "push", "on"]) 279 #expect(try argvOf(write) == ["notifications", "settings", "push", "on"])
275 #expect(model.pushEnabled) 280 #expect(model.pushEnabled)
276 } 281 }
277 282
278 @Test func removingADeviceSendsItsId() async throws { 283 /// See `settingPushReloadsItsOwnStateOnlyNotTheInbox` — the same
284 /// reasoning applies to a device removal.
285 @Test func removingADeviceSendsItsIdAndDoesNotReloadTheInbox() async throws {
279 let (client, stub) = try makeClient() 286 let (client, stub) = try makeClient()
280 let model = NotificationsViewModel(client: client) 287 let model = NotificationsViewModel(client: client)
281 288
282 stub.enqueue(.init(status: 200, json: """ 289 stub.enqueue(.init(status: 200, json: """
283 {"protocol_version":1,"exit_code":0} 290 {"protocol_version":1,"exit_code":0}
284 """)) 291 """))
285 stub.enqueue(.init(status: 200, json: page))
286 stub.enqueue(.init(status: 200, json: """ 292 stub.enqueue(.init(status: 200, json: """
287 {"protocol_version":1,"data":{"mail":true,"watch":false,"push":false},"exit_code":0} 293 {"protocol_version":1,"data":{"mail":true,"watch":false,"push":false},"exit_code":0}
288 """)) 294 """))
289 stub.enqueue(.init(status: 200, json: noDevices)) 295 stub.enqueue(.init(status: 200, json: noDevices))
290 await model.removeDevice(7) 296 await model.removeDevice(7)
291 297
298 #expect(stub.seen.count == 3)
292 let write = try #require(stub.seen.first { $0.method == "POST" }) 299 let write = try #require(stub.seen.first { $0.method == "POST" })
293 #expect(try argvOf(write) == ["notifications", "device", "remove", "7"]) 300 #expect(try argvOf(write) == ["notifications", "device", "remove", "7"])
294 #expect(model.devices.isEmpty) 301 #expect(model.devices.isEmpty)
295 } 302 }
303
304 /// A fast double-tap must not fire the write twice — `working` guards
305 /// it, the same pattern `markRead` already uses.
306 @Test func aSecondSetPushWhileWorkingIsIgnored() async throws {
307 let (client, stub) = try makeClient()
308 let model = NotificationsViewModel(client: client)
309
310 stub.enqueue(.init(status: 200, json: """
311 {"protocol_version":1,"exit_code":0}
312 """))
313 stub.enqueue(.init(status: 200, json: settingsPushOn))
314 stub.enqueue(.init(status: 200, json: noDevices))
315
316 async let first: Void = model.setPush(true)
317 await model.setPush(true)
318 _ = await first
319
320 // Only the first call's three requests; the guard drops the rest.
321 #expect(stub.seen.count == 3)
322 }
296} 323}