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 | 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 |