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