Commit 170a37d06b
Unsigned
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 |