Watch preference on the account screen !95

merged merged by cmc on 2026-09-12 06:00 UTC · krz/gitbay-ios:notifications-watch into main

4 files changed, +78 −14

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