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 {
58 /// Whether activity mail is on (v1.16.0); nil until read, or on a 58 /// Whether activity mail is on (v1.16.0); nil until read, or on a
59 /// server without the setting. Inbox rows are filed either way. 59 /// server without the setting. Inbox rows are filed either way.
60 private(set) var mailOn: Bool? 60 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?
61 /// The account's addresses; nil until read, or on a server without 65 /// The account's addresses; nil until read, or on a server without
62 /// `email list`. 66 /// `email list`.
63 private(set) var emails: [EmailAddress]? 67 private(set) var emails: [EmailAddress]?
@@ -83,23 +87,35 @@ final class AccountViewModel {
83 87
84 // MARK: - Notifications 88 // MARK: - Notifications
85 89
86 nonisolated private struct MailSetting: Decodable, Sendable { 90 nonisolated private struct NotificationSettings: Decodable, Sendable {
87 let mail: Bool 91 let mail: Bool
92 let watch: Bool?
88 } 93 }
89 94
90 func loadMailPreference() async { 95 func loadNotificationPreferences() async {
91 mailOn = (try? await client.read( 96 let settings = try? await client.read(
92 ["notifications", "settings", "show"], as: MailSetting.self))?.mail 97 ["notifications", "settings", "show"], as: NotificationSettings.self)
98 mailOn = settings?.mail
99 watchOn = settings?.watch
93 } 100 }
94 101
95 /// `notifications settings mail on|off`, then read the value back. 102 /// `notifications settings mail on|off`, then read the value back.
96 func setMail(_ on: Bool) async { 103 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 {
97 working = true 113 working = true
98 actionError = nil 114 actionError = nil
99 defer { working = false } 115 defer { working = false }
100 do { 116 do {
101 try await client.run(["notifications", "settings", "mail", on ? "on" : "off"]) 117 try await client.run(["notifications", "settings", name, on ? "on" : "off"])
102 await loadMailPreference() 118 await loadNotificationPreferences()
103 } catch let error as GitbayError { 119 } catch let error as GitbayError {
104 actionError = error.userFacingMessage 120 actionError = error.userFacingMessage
105 } catch { 121 } catch {
gitbay/Views/Account/AccountView.swift +12 −5
@@ -57,7 +57,7 @@ struct AccountView: View {
57 .navigationTitle("Account") 57 .navigationTitle("Account")
58 .navigationBarTitleDisplayMode(.inline) 58 .navigationBarTitleDisplayMode(.inline)
59 .task { await model.load() } 59 .task { await model.load() }
60 .task { await model.loadMailPreference() } 60 .task { await model.loadNotificationPreferences() }
61 .task { await model.loadEmails() } 61 .task { await model.loadEmails() }
62 .confirmationDialog( 62 .confirmationDialog(
63 "Remove \(removingEmail?.address ?? "")?", 63 "Remove \(removingEmail?.address ?? "")?",
@@ -77,7 +77,7 @@ struct AccountView: View {
77 } 77 }
78 .refreshable { 78 .refreshable {
79 await model.load() 79 await model.load()
80 await model.loadMailPreference() 80 await model.loadNotificationPreferences()
81 await model.loadEmails() 81 await model.loadEmails()
82 } 82 }
83 .sheet(isPresented: $addingSSH) { 83 .sheet(isPresented: $addingSSH) {
@@ -142,8 +142,9 @@ struct AccountView: View {
142 142
143 // MARK: - Sections 143 // MARK: - Sections
144 144
145 /// `notifications settings mail`: the mail half of notifications. The 145 /// `notifications settings mail` and `watch`. The inbox is filed
146 /// inbox is filed either way, and login links still arrive. 146 /// either way, and login links still arrive. Watch lands with
147 /// v1.19.0; an older server shows mail alone.
147 @ViewBuilder 148 @ViewBuilder
148 private var notificationsSection: some View { 149 private var notificationsSection: some View {
149 if let mailOn = model.mailOn { 150 if let mailOn = model.mailOn {
@@ -152,10 +153,16 @@ struct AccountView: View {
152 await model.setMail($0) 153 await model.setMail($0)
153 } 154 }
154 .disabled(model.working) 155 .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 }
155 } header: { 162 } header: {
156 Text("Notifications") 163 Text("Notifications")
157 } footer: { 164 } 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.")
159 } 166 }
160 } 167 }
161 } 168 }
gitbayTests/AccountTests.swift +27 −3
@@ -48,13 +48,15 @@ struct AccountViewModelTests {
48 /// v1.16.0: `notifications settings show` says whether activity mail 48 /// v1.16.0: `notifications settings show` says whether activity mail
49 /// is on; `notifications settings mail on|off` flips it. The read is 49 /// is on; `notifications settings mail on|off` flips it. The read is
50 /// separate from the keys, and an older server leaves it unknown. 50 /// separate from the keys, and an older server leaves it unknown.
51 /// A server before v1.19.0 answers without `watch`, which stays nil.
51 @Test func mailPreferenceLoadsAndFlips() async throws { 52 @Test func mailPreferenceLoadsAndFlips() async throws {
52 let (model, stub) = try await loadedModel() 53 let (model, stub) = try await loadedModel()
53 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show")) 54 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
54 55
55 await model.loadMailPreference() 56 await model.loadNotificationPreferences()
56 57
57 #expect(model.mailOn == true) 58 #expect(model.mailOn == true)
59 #expect(model.watchOn == nil)
58 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) 60 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
59 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show")) 61 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
60 62
@@ -66,13 +68,35 @@ struct AccountViewModelTests {
66 #expect(model.mailOn == false) 68 #expect(model.mailOn == false)
67 } 69 }
68 70
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 {
70 let (model, stub) = try await loadedModel() 93 let (model, stub) = try await loadedModel()
71 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show")) 94 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show"))
72 95
73 await model.loadMailPreference() 96 await model.loadNotificationPreferences()
74 97
75 #expect(model.mailOn == nil) 98 #expect(model.mailOn == nil)
99 #expect(model.watchOn == nil)
76 #expect(model.actionError == nil) 100 #expect(model.actionError == nil)
77 } 101 }
78 102
gitbayUITests/LiveSmokeUITests.swift +17
@@ -860,6 +860,23 @@ extension LiveSmokeUITests {
860 flipMail() 860 flipMail()
861 XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore") 861 XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore")
862 862
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
863 // A bogus verification code is refused, not swallowed. 880 // A bogus verification code is refused, not swallowed.
864 let code = app.descendants(matching: .any) 881 let code = app.descendants(matching: .any)
865 .matching(identifier: "email-code").firstMatch 882 .matching(identifier: "email-code").firstMatch