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 {
134134 ["notifications", "device", "list"], of: PushDevice.self)) ?? []
135135 }
136136
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.
137140 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"])
139143 await loadPushSettings()
140144 }
141145
146 /// See `setPush` — no inbox reload here either.
142147 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)])
144150 await loadPushSettings()
145151 }
146152
@@ -148,17 +154,32 @@ final class NotificationsViewModel {
148154 list.argv = ["notifications", "list"] + (showAll ? ["--all"] : [])
149155 }
150156
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.
151160 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 {
152171 working = true
153172 actionError = nil
154173 defer { working = false }
155174 do {
156175 try await client.run(argv)
157 await load()
176 return true
158177 } catch let error as GitbayError {
159178 actionError = error.userFacingMessage
179 return false
160180 } catch {
161181 actionError = GitbayError.transport(error).userFacingMessage
182 return false
162183 }
163184 }
164185}
gitbay/Views/Account/NotificationsView.swift +10
@@ -8,6 +8,7 @@ struct NotificationsView: View {
88
99 @State private var model: NotificationsViewModel
1010 @Environment(PushRegistrar.self) private var registrar
11 @Environment(\.scenePhase) private var scenePhase
1112 @State private var authorization: UNAuthorizationStatus = .notDetermined
1213
1314 init(client: GitbayClient) {
@@ -57,9 +58,18 @@ struct NotificationsView: View {
5758 .task { await model.load() }
5859 .task { await model.loadPushSettings() }
5960 .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 }
6069 .refreshable {
6170 await model.load()
6271 await model.loadPushSettings()
72 authorization = await registrar.authorizationStatus()
6373 }
6474 }
6575
gitbayTests/NotificationTests.swift +31 −4
@@ -258,39 +258,66 @@ struct NotificationsViewModelTests {
258258 #expect(model.devices.isEmpty)
259259 }
260260
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 {
262267 let (client, stub) = try makeClient()
263268 let model = NotificationsViewModel(client: client)
264269
265270 stub.enqueue(.init(status: 200, json: """
266271 {"protocol_version":1,"exit_code":0}
267272 """))
268 stub.enqueue(.init(status: 200, json: page))
269273 stub.enqueue(.init(status: 200, json: settingsPushOn))
270274 stub.enqueue(.init(status: 200, json: noDevices))
271275 await model.setPush(true)
272276
277 #expect(stub.seen.count == 3)
273278 let write = try #require(stub.seen.first { $0.method == "POST" })
274279 #expect(try argvOf(write) == ["notifications", "settings", "push", "on"])
275280 #expect(model.pushEnabled)
276281 }
277282
278 @Test func removingADeviceSendsItsId() async throws {
283 /// See `settingPushReloadsItsOwnStateOnlyNotTheInbox` — the same
284 /// reasoning applies to a device removal.
285 @Test func removingADeviceSendsItsIdAndDoesNotReloadTheInbox() async throws {
279286 let (client, stub) = try makeClient()
280287 let model = NotificationsViewModel(client: client)
281288
282289 stub.enqueue(.init(status: 200, json: """
283290 {"protocol_version":1,"exit_code":0}
284291 """))
285 stub.enqueue(.init(status: 200, json: page))
286292 stub.enqueue(.init(status: 200, json: """
287293 {"protocol_version":1,"data":{"mail":true,"watch":false,"push":false},"exit_code":0}
288294 """))
289295 stub.enqueue(.init(status: 200, json: noDevices))
290296 await model.removeDevice(7)
291297
298 #expect(stub.seen.count == 3)
292299 let write = try #require(stub.seen.first { $0.method == "POST" })
293300 #expect(try argvOf(write) == ["notifications", "device", "remove", "7"])
294301 #expect(model.devices.isEmpty)
295302 }
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 }
296323}