Commit 170a37d06b

170a37d06b3ceac4d3f3b8b940956d9c49228c05

parent: 282c068350

Unsigned

cmc <hello@cleberg.net> · 2026-09-12 06:00 UTC

Watch preference on the account screen (!95)

`notifications settings show` reports `watch` beside `mail` since v1.19.0 (krz/gitbay#194); `notifications settings watch on|off` flips it. A second `SettingToggle` in the Notifications section, shown only when the server answers with the field, so a v1.16–v1.18 instance still shows mail alone. The live account flow flips it and restores it.

Unit suite green on iPhone 17 Pro. The live smoke step is written but not run (needs a fresh ui-smoke token).

Closes #13

Layout: unified · split

gitbay/Account/AccountViewModel.swift +22 −6
@@ -58,6 +58,10 @@ final class AccountViewModel {
5858 /// Whether activity mail is on (v1.16.0); nil until read, or on a
5959 /// server without the setting. Inbox rows are filed either way.
6060 private(set) var mailOn: Bool?
61 /// Whether the account watches every issue and merge request on the
62 /// repositories it can write to (v1.19.0); nil until read, or on a
63 /// server whose `show` has no `watch`.
64 private(set) var watchOn: Bool?
6165 /// The account's addresses; nil until read, or on a server without
6266 /// `email list`.
6367 private(set) var emails: [EmailAddress]?
@@ -83,23 +87,35 @@ final class AccountViewModel {
8387
8488 // MARK: - Notifications
8589
86 nonisolated private struct MailSetting: Decodable, Sendable {
90 nonisolated private struct NotificationSettings: Decodable, Sendable {
8791 let mail: Bool
92 let watch: Bool?
8893 }
8994
90 func loadMailPreference() async {
91 mailOn = (try? await client.read(
92 ["notifications", "settings", "show"], as: MailSetting.self))?.mail
95 func loadNotificationPreferences() async {
96 let settings = try? await client.read(
97 ["notifications", "settings", "show"], as: NotificationSettings.self)
98 mailOn = settings?.mail
99 watchOn = settings?.watch
93100 }
94101
95102 /// `notifications settings mail on|off`, then read the value back.
96103 func setMail(_ on: Bool) async {
104 await setPreference("mail", on)
105 }
106
107 /// `notifications settings watch on|off`, then read the value back.
108 func setWatch(_ on: Bool) async {
109 await setPreference("watch", on)
110 }
111
112 private func setPreference(_ name: String, _ on: Bool) async {
97113 working = true
98114 actionError = nil
99115 defer { working = false }
100116 do {
101 try await client.run(["notifications", "settings", "mail", on ? "on" : "off"])
102 await loadMailPreference()
117 try await client.run(["notifications", "settings", name, on ? "on" : "off"])
118 await loadNotificationPreferences()
103119 } catch let error as GitbayError {
104120 actionError = error.userFacingMessage
105121 } catch {
gitbay/Views/Account/AccountView.swift +12 −5
@@ -57,7 +57,7 @@ struct AccountView: View {
5757 .navigationTitle("Account")
5858 .navigationBarTitleDisplayMode(.inline)
5959 .task { await model.load() }
60 .task { await model.loadMailPreference() }
60 .task { await model.loadNotificationPreferences() }
6161 .task { await model.loadEmails() }
6262 .confirmationDialog(
6363 "Remove \(removingEmail?.address ?? "")?",
@@ -77,7 +77,7 @@ struct AccountView: View {
7777 }
7878 .refreshable {
7979 await model.load()
80 await model.loadMailPreference()
80 await model.loadNotificationPreferences()
8181 await model.loadEmails()
8282 }
8383 .sheet(isPresented: $addingSSH) {
@@ -142,8 +142,9 @@ struct AccountView: View {
142142
143143 // MARK: - Sections
144144
145 /// `notifications settings mail`: the mail half of notifications. The
146 /// inbox is filed either way, and login links still arrive.
145 /// `notifications settings mail` and `watch`. The inbox is filed
146 /// either way, and login links still arrive. Watch lands with
147 /// v1.19.0; an older server shows mail alone.
147148 @ViewBuilder
148149 private var notificationsSection: some View {
149150 if let mailOn = model.mailOn {
@@ -152,10 +153,16 @@ struct AccountView: View {
152153 await model.setMail($0)
153154 }
154155 .disabled(model.working)
156 if let watchOn = model.watchOn {
157 SettingToggle(title: "Watch writable repositories", serverValue: watchOn) {
158 await model.setWatch($0)
159 }
160 .disabled(model.working)
161 }
155162 } header: {
156163 Text("Notifications")
157164 } footer: {
158 Text("Off keeps the inbox and stops the mail. Login links are not activity and still arrive.")
165 Text("Mail off keeps the inbox and stops the mail; login links are not activity and still arrive. Watching makes you a recipient of every issue and merge request on repositories you can write to.")
159166 }
160167 }
161168 }
gitbayTests/AccountTests.swift +27 −3
@@ -48,13 +48,15 @@ struct AccountViewModelTests {
4848 /// v1.16.0: `notifications settings show` says whether activity mail
4949 /// is on; `notifications settings mail on|off` flips it. The read is
5050 /// separate from the keys, and an older server leaves it unknown.
51 /// A server before v1.19.0 answers without `watch`, which stays nil.
5152 @Test func mailPreferenceLoadsAndFlips() async throws {
5253 let (model, stub) = try await loadedModel()
5354 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
5455
55 await model.loadMailPreference()
56 await model.loadNotificationPreferences()
5657
5758 #expect(model.mailOn == true)
59 #expect(model.watchOn == nil)
5860 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
5961 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
6062
@@ -66,13 +68,35 @@ struct AccountViewModelTests {
6668 #expect(model.mailOn == false)
6769 }
6870
69 @Test func anOlderServerLeavesTheMailPreferenceUnknown() async throws {
71 /// v1.19.0: `show` also reports `watch`, and `notifications settings
72 /// watch on|off` flips it.
73 @Test func watchPreferenceLoadsAndFlips() async throws {
74 let (model, stub) = try await loadedModel()
75 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
76
77 await model.loadNotificationPreferences()
78
79 #expect(model.mailOn == true)
80 #expect(model.watchOn == false)
81 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
82 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
83
84 await model.setWatch(true)
85
86 let write = try #require(stub.seen.first { $0.method == "POST" })
87 let (argv, _) = try argvOf(write)
88 #expect(argv == ["notifications", "settings", "watch", "on"])
89 #expect(model.watchOn == true)
90 }
91
92 @Test func anOlderServerLeavesTheNotificationPreferencesUnknown() async throws {
7093 let (model, stub) = try await loadedModel()
7194 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show"))
7295
73 await model.loadMailPreference()
96 await model.loadNotificationPreferences()
7497
7598 #expect(model.mailOn == nil)
99 #expect(model.watchOn == nil)
76100 #expect(model.actionError == nil)
77101 }
78102
gitbayUITests/LiveSmokeUITests.swift +17
@@ -860,6 +860,23 @@ extension LiveSmokeUITests {
860860 flipMail()
861861 XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore")
862862
863 // The watch preference (v1.19.0) flips the same way.
864 let watch = app.switches["Watch writable repositories"].firstMatch
865 XCTAssertTrue(scrollTo(watch), "watch toggle not reachable")
866 let innerWatch = watch.switches.firstMatch
867 let flipWatch: () -> Void = {
868 if innerWatch.exists && innerWatch != watch {
869 innerWatch.tap()
870 } else {
871 watch.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
872 }
873 }
874 let watchWasOn = (watch.value as? String) == "1"
875 flipWatch()
876 XCTAssertTrue(waitForValue(watch, watchWasOn ? "0" : "1", timeout: 10), "watch toggle did not persist")
877 flipWatch()
878 XCTAssertTrue(waitForValue(watch, watchWasOn ? "1" : "0", timeout: 10), "watch toggle did not restore")
879
863880 // A bogus verification code is refused, not swallowed.
864881 let code = app.descendants(matching: .any)
865882 .matching(identifier: "email-code").firstMatch