Commit 05b7bc82ab

05b7bc82ab8c64100497a9d005e83a047a5f64e0

parent: 34ae1d1cb0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-08 03:53 UTC

Activity mail switch on the account screen

notifications settings show and mail on|off (v1.16.0). The section
appears only when the server answers, so an older instance shows
nothing rather than a toggle that cannot write.

Layout: unified · split

gitbay/Account/AccountViewModel.swift +29
@@ -41,6 +41,9 @@ final class AccountViewModel {
4141 /// on its way.
4242 private(set) var notice: String?
4343 private(set) var working = false
44 /// Whether activity mail is on (v1.16.0); nil until read, or on a
45 /// server without the setting. Inbox rows are filed either way.
46 private(set) var mailOn: Bool?
4447
4548 private let client: GitbayClient
4649
@@ -61,6 +64,32 @@ final class AccountViewModel {
6164 }
6265 }
6366
67 // MARK: - Notifications
68
69 nonisolated private struct MailSetting: Decodable, Sendable {
70 let mail: Bool
71 }
72
73 func loadMailPreference() async {
74 mailOn = (try? await client.read(
75 ["notifications", "settings", "show"], as: MailSetting.self))?.mail
76 }
77
78 /// `notifications settings mail on|off`, then read the value back.
79 func setMail(_ on: Bool) async {
80 working = true
81 actionError = nil
82 defer { working = false }
83 do {
84 try await client.run(["notifications", "settings", "mail", on ? "on" : "off"])
85 await loadMailPreference()
86 } catch let error as GitbayError {
87 actionError = error.userFacingMessage
88 } catch {
89 actionError = GitbayError.transport(error).userFacingMessage
90 }
91 }
92
6493 // MARK: - SSH keys
6594
6695 /// `keys add [--scope full|git]` — the authorized_keys line travels
gitbay/Views/Account/AccountView.swift +24 −1
@@ -35,6 +35,7 @@ struct AccountView: View {
3535 }
3636 sshSection(loaded.sshKeys)
3737 pgpSection(loaded.pgpKeys)
38 notificationsSection
3839 // Feedback sits beside the email actions: with the
3940 // keyboard up, a banner at the top of the list is
4041 // scrolled out of existence.
@@ -55,7 +56,11 @@ struct AccountView: View {
5556 .navigationTitle("Account")
5657 .navigationBarTitleDisplayMode(.inline)
5758 .task { await model.load() }
58 .refreshable { await model.load() }
59 .task { await model.loadMailPreference() }
60 .refreshable {
61 await model.load()
62 await model.loadMailPreference()
63 }
5964 .sheet(isPresented: $addingSSH) {
6065 KeyPasteSheet(
6166 heading: "Add SSH Key",
@@ -118,6 +123,24 @@ struct AccountView: View {
118123
119124 // MARK: - Sections
120125
126 /// `notifications settings mail`: the mail half of notifications. The
127 /// inbox is filed either way, and login links still arrive.
128 @ViewBuilder
129 private var notificationsSection: some View {
130 if let mailOn = model.mailOn {
131 Section {
132 SettingToggle(title: "Activity by mail", serverValue: mailOn) {
133 await model.setMail($0)
134 }
135 .disabled(model.working)
136 } header: {
137 Text("Notifications")
138 } footer: {
139 Text("Off keeps the inbox and stops the mail. Login links are not activity and still arrive.")
140 }
141 }
142 }
143
121144 private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View {
122145 Section {
123146 ForEach(keys) { key in
gitbay/Views/Repos/RepoSettingsView.swift +1 −1
@@ -556,7 +556,7 @@ private struct ApprovalsStepper: View {
556556/// A Toggle backed by real local state that mirrors the server value.
557557/// A computed Binding whose setter spawns a Task proved unreliable under
558558/// synthesized taps; a plain @State toggle is not.
559private struct SettingToggle: View {
559struct SettingToggle: View {
560560
561561 let title: String
562562 let serverValue: Bool
gitbayTests/AccountTests.swift +31
@@ -45,6 +45,37 @@ struct AccountViewModelTests {
4545 return (model, stub)
4646 }
4747
48 /// v1.16.0: `notifications settings show` says whether activity mail
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.
51 @Test func mailPreferenceLoadsAndFlips() async throws {
52 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
55 await model.loadMailPreference()
56
57 #expect(model.mailOn == true)
58 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"))
60
61 await model.setMail(false)
62
63 let write = try #require(stub.seen.first { $0.method == "POST" })
64 let (argv, _) = try argvOf(write)
65 #expect(argv == ["notifications", "settings", "mail", "off"])
66 #expect(model.mailOn == false)
67 }
68
69 @Test func anOlderServerLeavesTheMailPreferenceUnknown() async throws {
70 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"))
72
73 await model.loadMailPreference()
74
75 #expect(model.mailOn == nil)
76 #expect(model.actionError == nil)
77 }
78
4879 @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws {
4980 let (model, _) = try await loadedModel()
5081
gitbayUITests/LiveSmokeUITests.swift +18
@@ -842,6 +842,24 @@ extension LiveSmokeUITests {
842842 .waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
843843 app.buttons["Cancel"].firstMatch.tap()
844844
845 // Activity mail flips off and back on; the value comes back from
846 // the server each time.
847 let mail = app.switches["Activity by mail"].firstMatch
848 XCTAssertTrue(scrollTo(mail), "mail toggle not reachable")
849 let innerMail = mail.switches.firstMatch
850 let flipMail: () -> Void = {
851 if innerMail.exists && innerMail != mail {
852 innerMail.tap()
853 } else {
854 mail.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
855 }
856 }
857 let mailWasOn = (mail.value as? String) == "1"
858 flipMail()
859 XCTAssertTrue(waitForValue(mail, mailWasOn ? "0" : "1", timeout: 10), "mail toggle did not persist")
860 flipMail()
861 XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore")
862
845863 // A bogus verification code is refused, not swallowed.
846864 let code = app.descendants(matching: .any)
847865 .matching(identifier: "email-code").firstMatch