Commit ac963099bc
Verified · cmc
Layout: unified · split
gitbay/Account/AccountViewModel.swift +35 −5
| @@ -86,6 +86,9 @@ final class AccountViewModel { | ||
| 86 | 86 | /// repositories it can write to (v1.19.0); nil until read, or on a |
| 87 | 87 | /// server whose `show` has no `watch`. |
| 88 | 88 | private(set) var watchOn: Bool? |
| 89 | /// Whether replying to notification mail comments (gitbay v1.39.0). | |
| 90 | private(set) var replyOn: Bool? | |
| 91 | private var replyAvailable: Bool? | |
| 89 | 92 | /// The account's addresses; nil until read, or on a server without |
| 90 | 93 | /// `email list`. |
| 91 | 94 | private(set) var emails: [EmailAddress]? |
| @@ -114,6 +117,13 @@ final class AccountViewModel { | ||
| 114 | 117 | nonisolated private struct NotificationSettings: Decodable, Sendable { |
| 115 | 118 | let mail: Bool |
| 116 | 119 | let watch: Bool? |
| 120 | let reply: Bool? | |
| 121 | let replyAvailable: Bool? | |
| 122 | ||
| 123 | enum CodingKeys: String, CodingKey { | |
| 124 | case mail, watch, reply | |
| 125 | case replyAvailable = "reply_available" | |
| 126 | } | |
| 117 | 127 | } |
| 118 | 128 | |
| 119 | 129 | func loadNotificationPreferences() async { |
| @@ -121,30 +131,50 @@ final class AccountViewModel { | ||
| 121 | 131 | ["notifications", "settings", "show"], as: NotificationSettings.self) |
| 122 | 132 | mailOn = settings?.mail |
| 123 | 133 | watchOn = settings?.watch |
| 134 | replyOn = settings?.reply | |
| 135 | replyAvailable = settings?.replyAvailable | |
| 136 | } | |
| 137 | ||
| 138 | /// The reply switch, offered where the instance reads replies to its | |
| 139 | /// mail (krz/gitbay#334). A server that does not say is offered it, | |
| 140 | /// and its refusal explains. | |
| 141 | var offersReply: Bool { replyOn != nil && replyAvailable != false } | |
| 142 | ||
| 143 | /// `notifications settings reply on|off`: issue and merge request mail | |
| 144 | /// carries a Reply-To, and a reply is posted as a comment. | |
| 145 | @discardableResult | |
| 146 | func setReply(_ on: Bool) async -> Bool { | |
| 147 | return await setPreference("reply", on) | |
| 124 | 148 | } |
| 125 | 149 | |
| 126 | 150 | /// `notifications settings mail on|off`, then read the value back. |
| 127 | func setMail(_ on: Bool) async { | |
| 128 | await setPreference("mail", on) | |
| 151 | @discardableResult | |
| 152 | func setMail(_ on: Bool) async -> Bool { | |
| 153 | return await setPreference("mail", on) | |
| 129 | 154 | } |
| 130 | 155 | |
| 131 | 156 | /// `notifications settings watch on|off`, then read the value back. |
| 132 | func setWatch(_ on: Bool) async { | |
| 133 | await setPreference("watch", on) | |
| 157 | @discardableResult | |
| 158 | func setWatch(_ on: Bool) async -> Bool { | |
| 159 | return await setPreference("watch", on) | |
| 134 | 160 | } |
| 135 | 161 | |
| 136 | private func setPreference(_ name: String, _ on: Bool) async { | |
| 162 | /// Whether the server took the change, so the switch can go back when | |
| 163 | /// it did not. | |
| 164 | private func setPreference(_ name: String, _ on: Bool) async -> Bool { | |
| 137 | 165 | working = true |
| 138 | 166 | actionError = nil |
| 139 | 167 | defer { working = false } |
| 140 | 168 | do { |
| 141 | 169 | try await client.run(["notifications", "settings", name, on ? "on" : "off"]) |
| 142 | 170 | await loadNotificationPreferences() |
| 171 | return true | |
| 143 | 172 | } catch let error as GitbayError { |
| 144 | 173 | actionError = error.userFacingMessage |
| 145 | 174 | } catch { |
| 146 | 175 | actionError = GitbayError.transport(error).userFacingMessage |
| 147 | 176 | } |
| 177 | return false | |
| 148 | 178 | } |
| 149 | 179 | |
| 150 | 180 | // MARK: - SSH keys |
gitbay/Views/Account/AccountView.swift +12 −5
| @@ -176,20 +176,27 @@ struct AccountView: View { | ||
| 176 | 176 | private var notificationsSection: some View { |
| 177 | 177 | if let mailOn = model.mailOn { |
| 178 | 178 | Section { |
| 179 | SettingToggle(title: "Activity by mail", serverValue: mailOn) { | |
| 179 | SettingToggle(title: "Activity by mail", serverValue: mailOn, accepted: { | |
| 180 | 180 | await model.setMail($0) |
| 181 | } | |
| 181 | }) | |
| 182 | 182 | .disabled(model.working) |
| 183 | 183 | if let watchOn = model.watchOn { |
| 184 | SettingToggle(title: "Watch writable repositories", serverValue: watchOn) { | |
| 184 | SettingToggle(title: "Watch writable repositories", serverValue: watchOn, accepted: { | |
| 185 | 185 | await model.setWatch($0) |
| 186 | } | |
| 186 | }) | |
| 187 | .disabled(model.working) | |
| 188 | } | |
| 189 | if model.offersReply, let replyOn = model.replyOn { | |
| 190 | SettingToggle(title: "Reply by mail", serverValue: replyOn, accepted: { | |
| 191 | await model.setReply($0) | |
| 192 | }) | |
| 187 | 193 | .disabled(model.working) |
| 188 | 194 | } |
| 189 | 195 | } header: { |
| 190 | 196 | Text("Notifications") |
| 191 | 197 | } footer: { |
| 192 | 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.") | |
| 198 | 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." | |
| 199 | + (model.offersReply ? " With reply by mail, answering issue and merge request mail from a verified address posts your reply as a comment." : "")) | |
| 193 | 200 | } |
| 194 | 201 | } |
| 195 | 202 | } |
gitbay/Views/Repos/RepoSettingsView.swift +20 −2
| @@ -706,15 +706,31 @@ private struct ApprovalsStepper: View { | ||
| 706 | 706 | /// A Toggle backed by real local state that mirrors the server value. |
| 707 | 707 | /// A computed Binding whose setter spawns a Task proved unreliable under |
| 708 | 708 | /// synthesized taps; a plain @State toggle is not. |
| 709 | /// | |
| 710 | /// A write that says whether the server took it lets the switch go back | |
| 711 | /// when it did not, rather than showing what was refused. A write that | |
| 712 | /// says nothing is taken as accepted, and the reload decides. | |
| 709 | 713 | struct SettingToggle: View { |
| 710 | 714 | |
| 711 | 715 | let title: String |
| 712 | 716 | let serverValue: Bool |
| 713 | let write: (Bool) async -> Void | |
| 717 | let write: (Bool) async -> Bool | |
| 714 | 718 | |
| 715 | 719 | @State private var isOn = false |
| 716 | 720 | @State private var seeded = false |
| 717 | 721 | |
| 722 | init(title: String, serverValue: Bool, write: @escaping (Bool) async -> Void) { | |
| 723 | self.title = title | |
| 724 | self.serverValue = serverValue | |
| 725 | self.write = { await write($0); return true } | |
| 726 | } | |
| 727 | ||
| 728 | init(title: String, serverValue: Bool, accepted write: @escaping (Bool) async -> Bool) { | |
| 729 | self.title = title | |
| 730 | self.serverValue = serverValue | |
| 731 | self.write = write | |
| 732 | } | |
| 733 | ||
| 718 | 734 | var body: some View { |
| 719 | 735 | Toggle(title, isOn: $isOn) |
| 720 | 736 | .onAppear { |
| @@ -728,7 +744,9 @@ struct SettingToggle: View { | ||
| 728 | 744 | } |
| 729 | 745 | .onChange(of: isOn) { _, newValue in |
| 730 | 746 | guard newValue != serverValue else { return } |
| 731 | Task { await write(newValue) } | |
| 747 | Task { | |
| 748 | if !(await write(newValue)) { isOn = !newValue } | |
| 749 | } | |
| 732 | 750 | } |
| 733 | 751 | } |
| 734 | 752 | } |
gitbayTests/ReplyByMailTests.swift added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private func show(_ fields: String) -> String { | |
| 16 | #"{"protocol_version":1,"data":{"mail":true,"watch":false,"# + fields + #"},"exit_code":0}"# | |
| 17 | } | |
| 18 | ||
| 19 | @MainActor | |
| 20 | struct ReplyByMailTests { | |
| 21 | ||
| 22 | @Test func theSwitchShowsWhereTheInstanceReadsReplies() async throws { | |
| 23 | let (client, stub) = try makeClient() | |
| 24 | stub.enqueue(.init(status: 200, json: show(#""reply":false,"reply_available":true"#), match: "argv=show")) | |
| 25 | let model = AccountViewModel(client: client) | |
| 26 | ||
| 27 | await model.loadNotificationPreferences() | |
| 28 | ||
| 29 | #expect(model.replyOn == false) | |
| 30 | #expect(model.offersReply) | |
| 31 | } | |
| 32 | ||
| 33 | @Test func noSwitchWhereTheInstanceDoesNot() async throws { | |
| 34 | let (client, stub) = try makeClient() | |
| 35 | stub.enqueue(.init(status: 200, json: show(#""reply":false,"reply_available":false"#), match: "argv=show")) | |
| 36 | let model = AccountViewModel(client: client) | |
| 37 | ||
| 38 | await model.loadNotificationPreferences() | |
| 39 | ||
| 40 | #expect(!model.offersReply) | |
| 41 | } | |
| 42 | ||
| 43 | /// A server without reply_available says nothing either way: the | |
| 44 | /// switch shows, and the refusal explains if it cannot be turned on. | |
| 45 | @Test func anOlderServerShowsTheSwitch() async throws { | |
| 46 | let (client, stub) = try makeClient() | |
| 47 | stub.enqueue(.init(status: 200, json: show(#""reply":false"#), match: "argv=show")) | |
| 48 | let model = AccountViewModel(client: client) | |
| 49 | ||
| 50 | await model.loadNotificationPreferences() | |
| 51 | ||
| 52 | #expect(model.offersReply) | |
| 53 | } | |
| 54 | ||
| 55 | @Test func turningItOnSendsReplyOn() async throws { | |
| 56 | let (client, stub) = try makeClient() | |
| 57 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd")) | |
| 58 | stub.enqueue(.init(status: 200, json: show(#""reply":true,"reply_available":true"#), match: "argv=show")) | |
| 59 | let model = AccountViewModel(client: client) | |
| 60 | ||
| 61 | await model.setReply(true) | |
| 62 | ||
| 63 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 64 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | |
| 65 | #expect(body["argv"] as? [String] == ["notifications", "settings", "reply", "on"]) | |
| 66 | #expect(model.replyOn == true) | |
| 67 | } | |
| 68 | } | |