Commit 4c7aac9884

4c7aac988477811e93ba4504d13c7b194c5e8dc1

parent: 34ae1d1cb0

Unsigned

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

Activity mail switch on the account screen (!91)

notifications settings show and mail on|off (v1.16.0, krz/gitbay#194). A Notifications section on the account screen with the shared SettingToggle, now internal. The section appears only when the server answers the show command, so an older instance shows nothing rather than a toggle that cannot write. The live account flow flips it and restores it.

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