Commit aa1e2d22c6
Verified · cmc
Layout: unified · split
gitbay/Account/NotificationsViewModel.swift +24 −3
| @@ -134,13 +134,19 @@ final class NotificationsViewModel { | ||
| 134 | 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 | 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 | 143 | await loadPushSettings() |
| 140 | 144 | } |
| 141 | 145 | |
| 146 | /// See `setPush` — no inbox reload here either. | |
| 142 | 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 | 150 | await loadPushSettings() |
| 145 | 151 | } |
| 146 | 152 | |
| @@ -148,17 +154,32 @@ final class NotificationsViewModel { | ||
| 148 | 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 | 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 | 171 | working = true |
| 153 | 172 | actionError = nil |
| 154 | 173 | defer { working = false } |
| 155 | 174 | do { |
| 156 | 175 | try await client.run(argv) |
| 157 | await load() | |
| 176 | return true | |
| 158 | 177 | } catch let error as GitbayError { |
| 159 | 178 | actionError = error.userFacingMessage |
| 179 | return false | |
| 160 | 180 | } catch { |
| 161 | 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 | 9 | @State private var model: NotificationsViewModel |
| 10 | 10 | @Environment(PushRegistrar.self) private var registrar |
| 11 | @Environment(\.scenePhase) private var scenePhase | |
| 11 | 12 | @State private var authorization: UNAuthorizationStatus = .notDetermined |
| 12 | 13 | |
| 13 | 14 | init(client: GitbayClient) { |
| @@ -57,9 +58,18 @@ struct NotificationsView: View { | ||
| 57 | 58 | .task { await model.load() } |
| 58 | 59 | .task { await model.loadPushSettings() } |
| 59 | 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 | 69 | .refreshable { |
| 61 | 70 | await model.load() |
| 62 | 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 | 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 | 267 | let (client, stub) = try makeClient() |
| 263 | 268 | let model = NotificationsViewModel(client: client) |
| 264 | 269 | |
| 265 | 270 | stub.enqueue(.init(status: 200, json: """ |
| 266 | 271 | {"protocol_version":1,"exit_code":0} |
| 267 | 272 | """)) |
| 268 | stub.enqueue(.init(status: 200, json: page)) | |
| 269 | 273 | stub.enqueue(.init(status: 200, json: settingsPushOn)) |
| 270 | 274 | stub.enqueue(.init(status: 200, json: noDevices)) |
| 271 | 275 | await model.setPush(true) |
| 272 | 276 | |
| 277 | #expect(stub.seen.count == 3) | |
| 273 | 278 | let write = try #require(stub.seen.first { $0.method == "POST" }) |
| 274 | 279 | #expect(try argvOf(write) == ["notifications", "settings", "push", "on"]) |
| 275 | 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 | 286 | let (client, stub) = try makeClient() |
| 280 | 287 | let model = NotificationsViewModel(client: client) |
| 281 | 288 | |
| 282 | 289 | stub.enqueue(.init(status: 200, json: """ |
| 283 | 290 | {"protocol_version":1,"exit_code":0} |
| 284 | 291 | """)) |
| 285 | stub.enqueue(.init(status: 200, json: page)) | |
| 286 | 292 | stub.enqueue(.init(status: 200, json: """ |
| 287 | 293 | {"protocol_version":1,"data":{"mail":true,"watch":false,"push":false},"exit_code":0} |
| 288 | 294 | """)) |
| 289 | 295 | stub.enqueue(.init(status: 200, json: noDevices)) |
| 290 | 296 | await model.removeDevice(7) |
| 291 | 297 | |
| 298 | #expect(stub.seen.count == 3) | |
| 292 | 299 | let write = try #require(stub.seen.first { $0.method == "POST" }) |
| 293 | 300 | #expect(try argvOf(write) == ["notifications", "device", "remove", "7"]) |
| 294 | 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 | } |