Commit ac963099bc

ac963099bc8d89a532fc833fbf2f5528cbc86b32

parent: fa307c5cb1

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 07:44 UTC

Reply by mail on and off

Notifications gain Reply by mail where the instance reads replies, as
the account page shows it (reply_available, krz/gitbay#334); a server
that does not say offers it, and its refusal explains.

The account's switches go back when the server refuses a change rather
than showing what it refused: SettingToggle takes a write that says
whether it was accepted. Settings screens whose writes do not say keep
the reload's word, as before.

Closes #33

Layout: unified · split

gitbay/Account/AccountViewModel.swift +35 −5
@@ -86,6 +86,9 @@ final class AccountViewModel {
86 /// repositories it can write to (v1.19.0); nil until read, or on a 86 /// repositories it can write to (v1.19.0); nil until read, or on a
87 /// server whose `show` has no `watch`. 87 /// server whose `show` has no `watch`.
88 private(set) var watchOn: Bool? 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 /// The account's addresses; nil until read, or on a server without 92 /// The account's addresses; nil until read, or on a server without
90 /// `email list`. 93 /// `email list`.
91 private(set) var emails: [EmailAddress]? 94 private(set) var emails: [EmailAddress]?
@@ -114,6 +117,13 @@ final class AccountViewModel {
114 nonisolated private struct NotificationSettings: Decodable, Sendable { 117 nonisolated private struct NotificationSettings: Decodable, Sendable {
115 let mail: Bool 118 let mail: Bool
116 let watch: Bool? 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 func loadNotificationPreferences() async { 129 func loadNotificationPreferences() async {
@@ -121,30 +131,50 @@ final class AccountViewModel {
121 ["notifications", "settings", "show"], as: NotificationSettings.self) 131 ["notifications", "settings", "show"], as: NotificationSettings.self)
122 mailOn = settings?.mail 132 mailOn = settings?.mail
123 watchOn = settings?.watch 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 /// `notifications settings mail on|off`, then read the value back. 150 /// `notifications settings mail on|off`, then read the value back.
127 func setMail(_ on: Bool) async { 151 @discardableResult
128 await setPreference("mail", on) 152 func setMail(_ on: Bool) async -> Bool {
153 return await setPreference("mail", on)
129 } 154 }
130 155
131 /// `notifications settings watch on|off`, then read the value back. 156 /// `notifications settings watch on|off`, then read the value back.
132 func setWatch(_ on: Bool) async { 157 @discardableResult
133 await setPreference("watch", on) 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 working = true 165 working = true
138 actionError = nil 166 actionError = nil
139 defer { working = false } 167 defer { working = false }
140 do { 168 do {
141 try await client.run(["notifications", "settings", name, on ? "on" : "off"]) 169 try await client.run(["notifications", "settings", name, on ? "on" : "off"])
142 await loadNotificationPreferences() 170 await loadNotificationPreferences()
171 return true
143 } catch let error as GitbayError { 172 } catch let error as GitbayError {
144 actionError = error.userFacingMessage 173 actionError = error.userFacingMessage
145 } catch { 174 } catch {
146 actionError = GitbayError.transport(error).userFacingMessage 175 actionError = GitbayError.transport(error).userFacingMessage
147 } 176 }
177 return false
148 } 178 }
149 179
150 // MARK: - SSH keys 180 // MARK: - SSH keys
gitbay/Views/Account/AccountView.swift +12 −5
@@ -176,20 +176,27 @@ struct AccountView: View {
176 private var notificationsSection: some View { 176 private var notificationsSection: some View {
177 if let mailOn = model.mailOn { 177 if let mailOn = model.mailOn {
178 Section { 178 Section {
179 SettingToggle(title: "Activity by mail", serverValue: mailOn) { 179 SettingToggle(title: "Activity by mail", serverValue: mailOn, accepted: {
180 await model.setMail($0) 180 await model.setMail($0)
181 } 181 })
182 .disabled(model.working) 182 .disabled(model.working)
183 if let watchOn = model.watchOn { 183 if let watchOn = model.watchOn {
184 SettingToggle(title: "Watch writable repositories", serverValue: watchOn) { 184 SettingToggle(title: "Watch writable repositories", serverValue: watchOn, accepted: {
185 await model.setWatch($0) 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 .disabled(model.working) 193 .disabled(model.working)
188 } 194 }
189 } header: { 195 } header: {
190 Text("Notifications") 196 Text("Notifications")
191 } footer: { 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/// A Toggle backed by real local state that mirrors the server value. 706/// A Toggle backed by real local state that mirrors the server value.
707/// A computed Binding whose setter spawns a Task proved unreliable under 707/// A computed Binding whose setter spawns a Task proved unreliable under
708/// synthesized taps; a plain @State toggle is not. 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.
709struct SettingToggle: View { 713struct SettingToggle: View {
710 714
711 let title: String 715 let title: String
712 let serverValue: Bool 716 let serverValue: Bool
713 let write: (Bool) async -> Void 717 let write: (Bool) async -> Bool
714 718
715 @State private var isOn = false 719 @State private var isOn = false
716 @State private var seeded = false 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 var body: some View { 734 var body: some View {
719 Toggle(title, isOn: $isOn) 735 Toggle(title, isOn: $isOn)
720 .onAppear { 736 .onAppear {
@@ -728,7 +744,9 @@ struct SettingToggle: View {
728 } 744 }
729 .onChange(of: isOn) { _, newValue in 745 .onChange(of: isOn) { _, newValue in
730 guard newValue != serverValue else { return } 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 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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
15private func show(_ fields: String) -> String {
16 #"{"protocol_version":1,"data":{"mail":true,"watch":false,"# + fields + #"},"exit_code":0}"#
17}
18
19@MainActor
20struct 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}