Commit 170a37d06b
Unsigned
Layout: unified · split
gitbay/Account/AccountViewModel.swift +22 −6
| @@ -58,6 +58,10 @@ final class AccountViewModel { | ||
| 58 | 58 | /// Whether activity mail is on (v1.16.0); nil until read, or on a |
| 59 | 59 | /// server without the setting. Inbox rows are filed either way. |
| 60 | 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 | 65 | /// The account's addresses; nil until read, or on a server without |
| 62 | 66 | /// `email list`. |
| 63 | 67 | private(set) var emails: [EmailAddress]? |
| @@ -83,23 +87,35 @@ final class AccountViewModel { | ||
| 83 | 87 | |
| 84 | 88 | // MARK: - Notifications |
| 85 | 89 | |
| 86 | nonisolated private struct MailSetting: Decodable, Sendable { | |
| 90 | nonisolated private struct NotificationSettings: Decodable, Sendable { | |
| 87 | 91 | let mail: Bool |
| 92 | let watch: Bool? | |
| 88 | 93 | } |
| 89 | 94 | |
| 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 | |
| 93 | 100 | } |
| 94 | 101 | |
| 95 | 102 | /// `notifications settings mail on|off`, then read the value back. |
| 96 | 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 | 113 | working = true |
| 98 | 114 | actionError = nil |
| 99 | 115 | defer { working = false } |
| 100 | 116 | 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() | |
| 103 | 119 | } catch let error as GitbayError { |
| 104 | 120 | actionError = error.userFacingMessage |
| 105 | 121 | } catch { |
gitbay/Views/Account/AccountView.swift +12 −5
| @@ -57,7 +57,7 @@ struct AccountView: View { | ||
| 57 | 57 | .navigationTitle("Account") |
| 58 | 58 | .navigationBarTitleDisplayMode(.inline) |
| 59 | 59 | .task { await model.load() } |
| 60 | .task { await model.loadMailPreference() } | |
| 60 | .task { await model.loadNotificationPreferences() } | |
| 61 | 61 | .task { await model.loadEmails() } |
| 62 | 62 | .confirmationDialog( |
| 63 | 63 | "Remove \(removingEmail?.address ?? "")?", |
| @@ -77,7 +77,7 @@ struct AccountView: View { | ||
| 77 | 77 | } |
| 78 | 78 | .refreshable { |
| 79 | 79 | await model.load() |
| 80 | await model.loadMailPreference() | |
| 80 | await model.loadNotificationPreferences() | |
| 81 | 81 | await model.loadEmails() |
| 82 | 82 | } |
| 83 | 83 | .sheet(isPresented: $addingSSH) { |
| @@ -142,8 +142,9 @@ struct AccountView: View { | ||
| 142 | 142 | |
| 143 | 143 | // MARK: - Sections |
| 144 | 144 | |
| 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. | |
| 147 | 148 | @ViewBuilder |
| 148 | 149 | private var notificationsSection: some View { |
| 149 | 150 | if let mailOn = model.mailOn { |
| @@ -152,10 +153,16 @@ struct AccountView: View { | ||
| 152 | 153 | await model.setMail($0) |
| 153 | 154 | } |
| 154 | 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 | 162 | } header: { |
| 156 | 163 | Text("Notifications") |
| 157 | 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 | 48 | /// v1.16.0: `notifications settings show` says whether activity mail |
| 49 | 49 | /// is on; `notifications settings mail on|off` flips it. The read is |
| 50 | 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 | 52 | @Test func mailPreferenceLoadsAndFlips() async throws { |
| 52 | 53 | let (model, stub) = try await loadedModel() |
| 53 | 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 | 58 | #expect(model.mailOn == true) |
| 59 | #expect(model.watchOn == nil) | |
| 58 | 60 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) |
| 59 | 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 | 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 | 93 | let (model, stub) = try await loadedModel() |
| 71 | 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 | 98 | #expect(model.mailOn == nil) |
| 99 | #expect(model.watchOn == nil) | |
| 76 | 100 | #expect(model.actionError == nil) |
| 77 | 101 | } |
| 78 | 102 | |
gitbayUITests/LiveSmokeUITests.swift +17
| @@ -860,6 +860,23 @@ extension LiveSmokeUITests { | ||
| 860 | 860 | flipMail() |
| 861 | 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 | 880 | // A bogus verification code is refused, not swallowed. |
| 864 | 881 | let code = app.descendants(matching: .any) |
| 865 | 882 | .matching(identifier: "email-code").firstMatch |