Commit 21e79f09ba
Verified · cmc
Layout: unified · split
gitbay/Account/NotificationsViewModel.swift +38
| @@ -60,6 +60,22 @@ nonisolated enum NotificationDestination: Hashable, Sendable { | |||
| 60 | @MainActor | 60 | @MainActor |
| 61 | final class NotificationsViewModel { | 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 | let list: PagedListModel<InboxNotification> | 79 | let list: PagedListModel<InboxNotification> |
| 64 | /// Off shows unread only, the default listing; on adds `--all`. A | 80 | /// Off shows unread only, the default listing; on adds `--all`. A |
| 65 | /// segmented control is quick to tap repeatedly, so a stale reload | 81 | /// segmented control is quick to tap repeatedly, so a stale reload |
| @@ -76,6 +92,10 @@ final class NotificationsViewModel { | |||
| 76 | 92 | ||
| 77 | private(set) var actionError: String? | 93 | private(set) var actionError: String? |
| 78 | private(set) var working = false | 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 | private let client: GitbayClient | 100 | private let client: GitbayClient |
| 81 | private var reloadTask: Task<Void, Never>? | 101 | private var reloadTask: Task<Void, Never>? |
| @@ -106,6 +126,24 @@ final class NotificationsViewModel { | |||
| 106 | await perform(["notifications", "read", "--all"]) | 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 | private func configureList() { | 147 | private func configureList() { |
| 110 | list.argv = ["notifications", "list"] + (showAll ? ["--all"] : []) | 148 | list.argv = ["notifications", "list"] + (showAll ? ["--all"] : []) |
| 111 | } | 149 | } |
gitbay/Views/Account/NotificationsView.swift +62 −1
| @@ -1,10 +1,14 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | import UIKit | ||
| 3 | import UserNotifications | ||
| 2 | 4 | ||
| 3 | /// The notification inbox: `notifications list [--all]`, paginated, with | 5 | /// The notification inbox: `notifications list [--all]`, paginated, with |
| 4 | /// a switch between the default unread-only listing and everything. | 6 | /// a switch between the default unread-only listing and everything. |
| 5 | struct NotificationsView: View { | 7 | struct NotificationsView: View { |
| 6 | 8 | ||
| 7 | @State private var model: NotificationsViewModel | 9 | @State private var model: NotificationsViewModel |
| 10 | @Environment(PushRegistrar.self) private var registrar | ||
| 11 | @State private var authorization: UNAuthorizationStatus = .notDetermined | ||
| 8 | 12 | ||
| 9 | init(client: GitbayClient) { | 13 | init(client: GitbayClient) { |
| 10 | _model = State(initialValue: NotificationsViewModel(client: client)) | 14 | _model = State(initialValue: NotificationsViewModel(client: client)) |
| @@ -12,6 +16,8 @@ struct NotificationsView: View { | |||
| 12 | 16 | ||
| 13 | var body: some View { | 17 | var body: some View { |
| 14 | List { | 18 | List { |
| 19 | pushSection | ||
| 20 | |||
| 15 | Picker("Filter", selection: Bindable(model).showAll) { | 21 | Picker("Filter", selection: Bindable(model).showAll) { |
| 16 | Text("Unread").tag(false) | 22 | Text("Unread").tag(false) |
| 17 | Text("All").tag(true) | 23 | Text("All").tag(true) |
| @@ -49,7 +55,62 @@ struct NotificationsView: View { | |||
| 49 | } | 55 | } |
| 50 | } | 56 | } |
| 51 | .task { await model.load() } | 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 | // One POST, no reload after a refusal. | 215 | // One POST, no reload after a refusal. |
| 216 | #expect(stub.seen.count == before + 1) | 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 | } |