Commit 21e79f09ba

21e79f09ba8b59ad9e6472c2d99d8894ff933ff3

parent: 5fc8e80dbe

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 23:18 UTC

push: the toggle and device list on the notifications screen

Permission is asked when the toggle is turned on, not on arrival: iOS
asks once, and a prompt sprung by navigation earns a reflexive
refusal that nothing afterwards can explain.

Ref krz/gitbay#89

Layout: unified · split

gitbay/Account/NotificationsViewModel.swift +38
@@ -60,6 +60,22 @@ nonisolated enum NotificationDestination: Hashable, Sendable {
6060@MainActor
6161final class NotificationsViewModel {
6262
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
6379 let list: PagedListModel<InboxNotification>
6480 /// Off shows unread only, the default listing; on adds `--all`. A
6581 /// segmented control is quick to tap repeatedly, so a stale reload
@@ -76,6 +92,10 @@ final class NotificationsViewModel {
7692
7793 private(set) var actionError: String?
7894 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] = []
7999
80100 private let client: GitbayClient
81101 private var reloadTask: Task<Void, Never>?
@@ -106,6 +126,24 @@ final class NotificationsViewModel {
106126 await perform(["notifications", "read", "--all"])
107127 }
108128
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
109147 private func configureList() {
110148 list.argv = ["notifications", "list"] + (showAll ? ["--all"] : [])
111149 }
gitbay/Views/Account/NotificationsView.swift +62 −1
@@ -1,10 +1,14 @@
11import SwiftUI
2import UIKit
3import UserNotifications
24
35/// The notification inbox: `notifications list [--all]`, paginated, with
46/// a switch between the default unread-only listing and everything.
57struct NotificationsView: View {
68
79 @State private var model: NotificationsViewModel
10 @Environment(PushRegistrar.self) private var registrar
11 @State private var authorization: UNAuthorizationStatus = .notDetermined
812
913 init(client: GitbayClient) {
1014 _model = State(initialValue: NotificationsViewModel(client: client))
@@ -12,6 +16,8 @@ struct NotificationsView: View {
1216
1317 var body: some View {
1418 List {
19 pushSection
20
1521 Picker("Filter", selection: Bindable(model).showAll) {
1622 Text("Unread").tag(false)
1723 Text("All").tag(true)
@@ -49,7 +55,62 @@ struct NotificationsView: View {
4955 }
5056 }
5157 .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)
53114 }
54115}
55116
gitbayTests/NotificationTests.swift +78
@@ -215,4 +215,82 @@ struct NotificationsViewModelTests {
215215 // One POST, no reload after a refusal.
216216 #expect(stub.seen.count == before + 1)
217217 }
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 }
218296}