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 | /// 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. |
| 559 | private struct SettingToggle: View { |
559 | struct 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 |