Commit 21e79f09ba
Verified · cmc
Layout: unified · split
gitbay/Account/NotificationsViewModel.swift +38
| @@ -60,6 +60,22 @@ nonisolated enum NotificationDestination: Hashable, Sendable { | ||
| 60 | 60 | @MainActor |
| 61 | 61 | final class NotificationsViewModel { |
| 62 | 62 | |
| 63 | /// One row of `notifications device list`. `token` is already | |
| 64 | /// truncated by the server — never the whole value. | |
| 65 | nonisolated struct PushDevice: Decodable, Sendable, Identifiable { | |
| 66 | let id: Int64 | |
| 67 | let label: String | |
| 68 | let token: String | |
| 69 | let added: String | |
| 70 | } | |
| 71 | ||
| 72 | /// `notifications settings show` reports mail, watch and push. | |
| 73 | nonisolated struct PushSettings: Decodable, Sendable { | |
| 74 | let mail: Bool | |
| 75 | let watch: Bool | |
| 76 | let push: Bool | |
| 77 | } | |
| 78 | ||
| 63 | 79 | let list: PagedListModel<InboxNotification> |
| 64 | 80 | /// Off shows unread only, the default listing; on adds `--all`. A |
| 65 | 81 | /// segmented control is quick to tap repeatedly, so a stale reload |
| @@ -76,6 +92,10 @@ final class NotificationsViewModel { | ||
| 76 | 92 | |
| 77 | 93 | private(set) var actionError: String? |
| 78 | 94 | private(set) var working = false |
| 95 | /// The server-side push preference; both this and iOS authorization | |
| 96 | /// must be on for push to arrive. | |
| 97 | private(set) var pushEnabled = false | |
| 98 | private(set) var devices: [PushDevice] = [] | |
| 79 | 99 | |
| 80 | 100 | private let client: GitbayClient |
| 81 | 101 | private var reloadTask: Task<Void, Never>? |
| @@ -106,6 +126,24 @@ final class NotificationsViewModel { | ||
| 106 | 126 | await perform(["notifications", "read", "--all"]) |
| 107 | 127 | } |
| 108 | 128 | |
| 129 | func loadPushSettings() async { | |
| 130 | guard let settings = try? await client.read( | |
| 131 | ["notifications", "settings", "show"], as: PushSettings.self) else { return } | |
| 132 | pushEnabled = settings.push | |
| 133 | devices = (try? await client.readList( | |
| 134 | ["notifications", "device", "list"], of: PushDevice.self)) ?? [] | |
| 135 | } | |
| 136 | ||
| 137 | func setPush(_ on: Bool) async { | |
| 138 | await perform(["notifications", "settings", "push", on ? "on" : "off"]) | |
| 139 | await loadPushSettings() | |
| 140 | } | |
| 141 | ||
| 142 | func removeDevice(_ id: Int64) async { | |
| 143 | await perform(["notifications", "device", "remove", String(id)]) | |
| 144 | await loadPushSettings() | |
| 145 | } | |
| 146 | ||
| 109 | 147 | private func configureList() { |
| 110 | 148 | list.argv = ["notifications", "list"] + (showAll ? ["--all"] : []) |
| 111 | 149 | } |
gitbay/Views/Account/NotificationsView.swift +62 −1
| @@ -1,10 +1,14 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | import UIKit | |
| 3 | import UserNotifications | |
| 2 | 4 | |
| 3 | 5 | /// The notification inbox: `notifications list [--all]`, paginated, with |
| 4 | 6 | /// a switch between the default unread-only listing and everything. |
| 5 | 7 | struct NotificationsView: View { |
| 6 | 8 | |
| 7 | 9 | @State private var model: NotificationsViewModel |
| 10 | @Environment(PushRegistrar.self) private var registrar | |
| 11 | @State private var authorization: UNAuthorizationStatus = .notDetermined | |
| 8 | 12 | |
| 9 | 13 | init(client: GitbayClient) { |
| 10 | 14 | _model = State(initialValue: NotificationsViewModel(client: client)) |
| @@ -12,6 +16,8 @@ struct NotificationsView: View { | ||
| 12 | 16 | |
| 13 | 17 | var body: some View { |
| 14 | 18 | List { |
| 19 | pushSection | |
| 20 | ||
| 15 | 21 | Picker("Filter", selection: Bindable(model).showAll) { |
| 16 | 22 | Text("Unread").tag(false) |
| 17 | 23 | Text("All").tag(true) |
| @@ -49,7 +55,62 @@ struct NotificationsView: View { | ||
| 49 | 55 | } |
| 50 | 56 | } |
| 51 | 57 | .task { await model.load() } |
| 52 | .refreshable { await model.load() } | |
| 58 | .task { await model.loadPushSettings() } | |
| 59 | .task { authorization = await registrar.authorizationStatus() } | |
| 60 | .refreshable { | |
| 61 | await model.load() | |
| 62 | await model.loadPushSettings() | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | /// Push has two independent switches — iOS authorization and the | |
| 67 | /// server preference — and the toggle must read false when either | |
| 68 | /// one is off. Permission is asked here, on the user turning the | |
| 69 | /// toggle on, not on the screen appearing: iOS asks once per | |
| 70 | /// install, and a prompt sprung by navigation earns a reflexive | |
| 71 | /// "Don't Allow" that nothing afterwards can explain. | |
| 72 | private var pushSection: some View { | |
| 73 | Section("Push notifications") { | |
| 74 | Toggle("Push notifications", isOn: Binding( | |
| 75 | get: { authorization == .authorized && model.pushEnabled }, | |
| 76 | set: { on in Task { await setPush(on) } } | |
| 77 | )) | |
| 78 | .disabled(authorization == .denied) | |
| 79 | ||
| 80 | if authorization == .denied { | |
| 81 | GBNotice("Notifications are turned off for gitbay in Settings.", .gbWarn) | |
| 82 | Button("Open Settings") { | |
| 83 | if let url = URL(string: UIApplication.openSettingsURLString) { | |
| 84 | UIApplication.shared.open(url) | |
| 85 | } | |
| 86 | } | |
| 87 | .font(.gbSans(.subheadline)) | |
| 88 | } | |
| 89 | ||
| 90 | ForEach(model.devices) { device in | |
| 91 | VStack(alignment: .leading, spacing: 2) { | |
| 92 | Text(device.label) | |
| 93 | .font(.gbSans(.subheadline)) | |
| 94 | Text(device.token) | |
| 95 | .font(.gbMono(.caption)) | |
| 96 | .foregroundStyle(.secondary) | |
| 97 | } | |
| 98 | .swipeActions { | |
| 99 | Button("Remove", role: .destructive) { | |
| 100 | Task { await model.removeDevice(device.id) } | |
| 101 | } | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | private func setPush(_ on: Bool) async { | |
| 108 | if on, authorization == .notDetermined { | |
| 109 | let granted = await registrar.requestPushPermission() | |
| 110 | authorization = await registrar.authorizationStatus() | |
| 111 | guard granted else { return } | |
| 112 | } | |
| 113 | await model.setPush(on) | |
| 53 | 114 | } |
| 54 | 115 | } |
| 55 | 116 | |
gitbayTests/NotificationTests.swift +78
| @@ -215,4 +215,82 @@ struct NotificationsViewModelTests { | ||
| 215 | 215 | // One POST, no reload after a refusal. |
| 216 | 216 | #expect(stub.seen.count == before + 1) |
| 217 | 217 | } |
| 218 | ||
| 219 | // MARK: - Push | |
| 220 | ||
| 221 | private let settingsPushOn = """ | |
| 222 | {"protocol_version":1,"data":{"mail":true,"watch":false,"push":true},"exit_code":0} | |
| 223 | """ | |
| 224 | ||
| 225 | private let noDevices = """ | |
| 226 | {"protocol_version":1,"data":[],"exit_code":0} | |
| 227 | """ | |
| 228 | ||
| 229 | @Test func loadingPushSettingsReadsThePreferenceAndTheDevices() async throws { | |
| 230 | let (client, stub) = try makeClient() | |
| 231 | stub.enqueue(.init(status: 200, json: settingsPushOn)) | |
| 232 | stub.enqueue(.init(status: 200, json: """ | |
| 233 | {"protocol_version":1,"data":[\ | |
| 234 | {"id":1,"label":"cmc's iPhone","token":"a1b2…","added":"2026-09-20T00:00:00Z"}\ | |
| 235 | ],"exit_code":0} | |
| 236 | """)) | |
| 237 | let model = NotificationsViewModel(client: client) | |
| 238 | await model.loadPushSettings() | |
| 239 | ||
| 240 | #expect(model.pushEnabled) | |
| 241 | #expect(model.devices.map(\.id) == [1]) | |
| 242 | // The token is whatever the server sent — this model never | |
| 243 | // requests or reconstructs the full value. | |
| 244 | #expect(model.devices.first?.token == "a1b2…") | |
| 245 | } | |
| 246 | ||
| 247 | /// `push` is absent on a server that predates it; the read fails to | |
| 248 | /// decode and the defaults stand rather than crashing. | |
| 249 | @Test func aServerWithoutPushLeavesTheDefaults() async throws { | |
| 250 | let (client, stub) = try makeClient() | |
| 251 | stub.enqueue(.init(status: 200, json: """ | |
| 252 | {"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0} | |
| 253 | """)) | |
| 254 | let model = NotificationsViewModel(client: client) | |
| 255 | await model.loadPushSettings() | |
| 256 | ||
| 257 | #expect(model.pushEnabled == false) | |
| 258 | #expect(model.devices.isEmpty) | |
| 259 | } | |
| 260 | ||
| 261 | @Test func settingPushSendsOnAndReloadsTheState() async throws { | |
| 262 | let (client, stub) = try makeClient() | |
| 263 | let model = NotificationsViewModel(client: client) | |
| 264 | ||
| 265 | stub.enqueue(.init(status: 200, json: """ | |
| 266 | {"protocol_version":1,"exit_code":0} | |
| 267 | """)) | |
| 268 | stub.enqueue(.init(status: 200, json: page)) | |
| 269 | stub.enqueue(.init(status: 200, json: settingsPushOn)) | |
| 270 | stub.enqueue(.init(status: 200, json: noDevices)) | |
| 271 | await model.setPush(true) | |
| 272 | ||
| 273 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 274 | #expect(try argvOf(write) == ["notifications", "settings", "push", "on"]) | |
| 275 | #expect(model.pushEnabled) | |
| 276 | } | |
| 277 | ||
| 278 | @Test func removingADeviceSendsItsId() async throws { | |
| 279 | let (client, stub) = try makeClient() | |
| 280 | let model = NotificationsViewModel(client: client) | |
| 281 | ||
| 282 | stub.enqueue(.init(status: 200, json: """ | |
| 283 | {"protocol_version":1,"exit_code":0} | |
| 284 | """)) | |
| 285 | stub.enqueue(.init(status: 200, json: page)) | |
| 286 | stub.enqueue(.init(status: 200, json: """ | |
| 287 | {"protocol_version":1,"data":{"mail":true,"watch":false,"push":false},"exit_code":0} | |
| 288 | """)) | |
| 289 | stub.enqueue(.init(status: 200, json: noDevices)) | |
| 290 | await model.removeDevice(7) | |
| 291 | ||
| 292 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 293 | #expect(try argvOf(write) == ["notifications", "device", "remove", "7"]) | |
| 294 | #expect(model.devices.isEmpty) | |
| 295 | } | |
| 218 | 296 | } |