Reply by mail on and off !169

merged merged by cmc on 2026-10-14 08:08 UTC · krz/gitbay-ios:reply-by-mail into main

4 files changed, +135 −12

Layout: unified · split

gitbay/Account/AccountViewModel.swift +35 −5
@@ -86,6 +86,9 @@ final class AccountViewModel {
8686 /// repositories it can write to (v1.19.0); nil until read, or on a
8787 /// server whose `show` has no `watch`.
8888 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?
8992 /// The account's addresses; nil until read, or on a server without
9093 /// `email list`.
9194 private(set) var emails: [EmailAddress]?
@@ -114,6 +117,13 @@ final class AccountViewModel {
114117 nonisolated private struct NotificationSettings: Decodable, Sendable {
115118 let mail: Bool
116119 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 }
117127 }
118128
119129 func loadNotificationPreferences() async {
@@ -121,30 +131,50 @@ final class AccountViewModel {
121131 ["notifications", "settings", "show"], as: NotificationSettings.self)
122132 mailOn = settings?.mail
123133 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)
124148 }
125149
126150 /// `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)
129154 }
130155
131156 /// `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)
134160 }
135161
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 {
137165 working = true
138166 actionError = nil
139167 defer { working = false }
140168 do {
141169 try await client.run(["notifications", "settings", name, on ? "on" : "off"])
142170 await loadNotificationPreferences()
171 return true
143172 } catch let error as GitbayError {
144173 actionError = error.userFacingMessage
145174 } catch {
146175 actionError = GitbayError.transport(error).userFacingMessage
147176 }
177 return false
148178 }
149179
150180 // MARK: - SSH keys
gitbay/Views/Account/AccountView.swift +12 −5
@@ -176,20 +176,27 @@ struct AccountView: View {
176176 private var notificationsSection: some View {
177177 if let mailOn = model.mailOn {
178178 Section {
179 SettingToggle(title: "Activity by mail", serverValue: mailOn) {
179 SettingToggle(title: "Activity by mail", serverValue: mailOn, accepted: {
180180 await model.setMail($0)
181 }
181 })
182182 .disabled(model.working)
183183 if let watchOn = model.watchOn {
184 SettingToggle(title: "Watch writable repositories", serverValue: watchOn) {
184 SettingToggle(title: "Watch writable repositories", serverValue: watchOn, accepted: {
185185 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 })
187193 .disabled(model.working)
188194 }
189195 } header: {
190196 Text("Notifications")
191197 } 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." : ""))
193200 }
194201 }
195202 }
gitbay/Views/Repos/RepoSettingsView.swift +20 −2
@@ -706,15 +706,31 @@ private struct ApprovalsStepper: View {
706706/// A Toggle backed by real local state that mirrors the server value.
707707/// A computed Binding whose setter spawns a Task proved unreliable under
708708/// 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.
709713struct SettingToggle: View {
710714
711715 let title: String
712716 let serverValue: Bool
713 let write: (Bool) async -> Void
717 let write: (Bool) async -> Bool
714718
715719 @State private var isOn = false
716720 @State private var seeded = false
717721
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
718734 var body: some View {
719735 Toggle(title, isOn: $isOn)
720736 .onAppear {
@@ -728,7 +744,9 @@ struct SettingToggle: View {
728744 }
729745 .onChange(of: isOn) { _, newValue in
730746 guard newValue != serverValue else { return }
731 Task { await write(newValue) }
747 Task {
748 if !(await write(newValue)) { isOn = !newValue }
749 }
732750 }
733751 }
734752}
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}