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 {
41 /// on its way. 41 /// on its way.
42 private(set) var notice: String? 42 private(set) var notice: String?
43 private(set) var working = false 43 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?
44 47
45 private let client: GitbayClient 48 private let client: GitbayClient
46 49
@@ -61,6 +64,32 @@ final class AccountViewModel {
61 } 64 }
62 } 65 }
63 66
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
64 // MARK: - SSH keys 93 // MARK: - SSH keys
65 94
66 /// `keys add [--scope full|git]` — the authorized_keys line travels 95 /// `keys add [--scope full|git]` — the authorized_keys line travels
gitbay/Views/Account/AccountView.swift +24 −1
@@ -35,6 +35,7 @@ struct AccountView: View {
35 } 35 }
36 sshSection(loaded.sshKeys) 36 sshSection(loaded.sshKeys)
37 pgpSection(loaded.pgpKeys) 37 pgpSection(loaded.pgpKeys)
38 notificationsSection
38 // Feedback sits beside the email actions: with the 39 // Feedback sits beside the email actions: with the
39 // keyboard up, a banner at the top of the list is 40 // keyboard up, a banner at the top of the list is
40 // scrolled out of existence. 41 // scrolled out of existence.
@@ -55,7 +56,11 @@ struct AccountView: View {
55 .navigationTitle("Account") 56 .navigationTitle("Account")
56 .navigationBarTitleDisplayMode(.inline) 57 .navigationBarTitleDisplayMode(.inline)
57 .task { await model.load() } 58 .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 }
59 .sheet(isPresented: $addingSSH) { 64 .sheet(isPresented: $addingSSH) {
60 KeyPasteSheet( 65 KeyPasteSheet(
61 heading: "Add SSH Key", 66 heading: "Add SSH Key",
@@ -118,6 +123,24 @@ struct AccountView: View {
118 123
119 // MARK: - Sections 124 // MARK: - Sections
120 125
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
121 private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View { 144 private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View {
122 Section { 145 Section {
123 ForEach(keys) { key in 146 ForEach(keys) { key in
gitbay/Views/Repos/RepoSettingsView.swift +1 −1
@@ -556,7 +556,7 @@ private struct ApprovalsStepper: View {
556/// A Toggle backed by real local state that mirrors the server value. 556/// A Toggle backed by real local state that mirrors the server value.
557/// A computed Binding whose setter spawns a Task proved unreliable under 557/// A computed Binding whose setter spawns a Task proved unreliable under
558/// synthesized taps; a plain @State toggle is not. 558/// synthesized taps; a plain @State toggle is not.
559private struct SettingToggle: View { 559struct SettingToggle: View {
560 560
561 let title: String 561 let title: String
562 let serverValue: Bool 562 let serverValue: Bool
gitbayTests/AccountTests.swift +31
@@ -45,6 +45,37 @@ struct AccountViewModelTests {
45 return (model, stub) 45 return (model, stub)
46 } 46 }
47 47
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
48 @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws { 79 @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws {
49 let (model, _) = try await loadedModel() 80 let (model, _) = try await loadedModel()
50 81
gitbayUITests/LiveSmokeUITests.swift +18
@@ -842,6 +842,24 @@ extension LiveSmokeUITests {
842 .waitForExistence(timeout: 15), "invalid-key refusal not surfaced") 842 .waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
843 app.buttons["Cancel"].firstMatch.tap() 843 app.buttons["Cancel"].firstMatch.tap()
844 844
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
845 // A bogus verification code is refused, not swallowed. 863 // A bogus verification code is refused, not swallowed.
846 let code = app.descendants(matching: .any) 864 let code = app.descendants(matching: .any)
847 .matching(identifier: "email-code").firstMatch 865 .matching(identifier: "email-code").firstMatch