Commit aa1e2d22c6
Verified · cmc
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 | } |