Commit e576103c5e

e576103c5eba2f64e4008d7720381a3d66fa349d

parent: a179ef304c

Unsigned

cmc <hello@cleberg.net> · 2026-09-06 23:16 UTC

Notification inbox (!52)

`notifications list` reaches the app, paged with the same cursors every
other list uses. The default is unread only, with a segmented control for
everything; rows mark read on swipe, and there is a mark-all action.

Each row follows its own `path` rather than its `repo` field, so a row
always goes where its link says. Paths the app has no screen for render
as plain, untappable rows instead of guessing — six malformed shapes are
pinned by tests.

The unread badge on the Dashboard costs no extra request: `dashboard`
already carries `unread`, which the server added for exactly this, and
the app simply had not decoded it.

The wire model is `InboxNotification` rather than `Notification`, so it
does not shadow Foundation's inside this module.

Closes the `notification inbox` parity row.

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr06-notifications.md added +345
@@ -0,0 +1,345 @@
1# MR 6: Notification inbox Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close the `notification inbox` parity row — read the inbox, mark things read, and carry the unread count on the Dashboard.
6
7**Architecture:** `notifications list` is paginated, so it goes through the existing `PagedListModel` like every other list. `notifications read` marks one or all. The unread badge costs no extra request: `dashboard` already emits `unread` for exactly this purpose and iOS simply does not decode it yet.
8
9**Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md`
10
11## Global Constraints
12
13- Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s.
14- Swift Testing only — never XCTest.
15- `gitbayTests` is hermetic and offline; network goes through `StubProtocol`.
16- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit.
17- The label model is `IssueLabel`, never `Label`.
18- Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer.
19- Never commit to `main`.
20
21**The commands, verbatim from the registry:**
22
23```
24notifications list [--all] [--limit <n>] [--cursor <c>]
25notifications read <id>... | --all
26```
27
28`notifications list` **is paginated** — unlike `search` in MR 3 — so it uses `PagedListModel`, which appends `--limit`/`--cursor` itself. The default listing is **unread only**; `--all` includes read ones.
29
30**The item shape** (`internal/control/notifications.go:117-125`):
31
32```json
33{"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",
34 "summary":"build 966 failed: test on runner-podman-exec-144",
35 "path":"krz/gitbay/builds/966","created_at":"2026-09-06T22:14:46.912Z",
36 "read_at":"2026-09-06T22:20:24.040Z"}
37```
38
39Everything is always present except `read_at`, which is `omitempty` — **absent means unread**, and that is the common case for the default listing.
40
41**Counting tests reliably:**
42
43```bash
44grep -cE "^Test case '[^']*' passed" /tmp/out.txt
45grep -cE "^Test case '[^']*' failed" /tmp/out.txt
46grep -cE "^Test case '[^']*' skipped" /tmp/out.txt
47grep -rhoE "@Test(\([^)]*\))? func" gitbayTests/*.swift | wc -l
48```
49
50`declared - skipped == passed`. One test is always skipped (`LiveInstanceTests`).
51
52Baseline: **285 declared, 284 passed, 1 skipped, 0 failed.**
53
54---
55
56## File Structure
57
58| File | Responsibility |
59|------|----------------|
60| `gitbay/Account/NotificationsViewModel.swift` (create) | `Notification` wire model, routing, and the paged view model |
61| `gitbayTests/NotificationTests.swift` (create) | Every test in this plan |
62| `gitbay/Views/Account/NotificationsView.swift` (create) | The inbox screen |
63| `gitbay/Dashboard/DashboardModels.swift` (modify) | Decode `unread` |
64| `gitbay/Views/Dashboard/DashboardView.swift` (modify) | Badge + entry point |
65| `gitbay/Views/Repos/RepoRoute.swift` (modify) | `notifications` case |
66| `gitbay/ContentView.swift` (modify) | Route to `NotificationsView` |
67
68---
69
70### Task 1: The model, its routing, and the view model
71
72**Files:** Create `gitbay/Account/NotificationsViewModel.swift`; test in `gitbayTests/NotificationTests.swift`
73
74**Interfaces produced:**
75- `nonisolated struct Notification: Decodable, Sendable, Hashable, Identifiable` — `id: Int64`, `repo: String`, `kind: String`, `actor: String`, `summary: String`, `path: String`, `createdAt: Date`, `readAt: Date?`; `var isUnread: Bool`; `var destination: NotificationDestination?`
76- `nonisolated enum NotificationDestination: Hashable, Sendable` — `.issue(repo:number:)`, `.mr(repo:number:)`, `.build(repo:number:)`
77- `@Observable @MainActor final class NotificationsViewModel` — `list: PagedListModel<Notification>`, `showAll: Bool`, `state`, `actionError`, `working`, `load()`, `markRead(_:)`, `markAllRead()`
78
79**The routing table**, derived from the server (`internal/control/issue.go:153,279,349`, `mr.go:322,702,778,832,1173,1198,1382`, `diffcomment.go:111`, `build.go:522`, `deps/worker.go:249`) and confirmed against live data. `path` is a **web-style route**, `<owner>/<repo>/<section>/<n>`:
80
81| `kind` | `path` | destination |
82|---------|------------------------------|-------------|
83| `issue` | `<owner>/<repo>/issues/<n>` | `.issue` |
84| `mr` | `<owner>/<repo>/mrs/<n>` | `.mr` |
85| `build` | `<owner>/<repo>/builds/<n>` | `.build` |
86
87**The merge request segment is `mrs`, not `merge_requests`.** Matching the wrong spelling produces a silently untappable row — no error, no crash, just a row that does nothing.
88
89Parse defensively: split on `/`, require exactly four components, require the last to be an integer, and return `nil` for anything else. A `nil` destination renders an untappable row rather than crashing — the same rule the search results screen follows for a missing number.
90
91- [ ] **Step 1: Write the failing tests**
92
93Create `gitbayTests/NotificationTests.swift`:
94
95```swift
96import Foundation
97import Testing
98@testable import gitbay
99
100private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
101 let box = StubProtocol.box()
102 let client = GitbayClient(
103 instance: try GitbayInstance(url: "https://gitbay.org"),
104 token: "test-token",
105 session: box.session()
106 )
107 return (client, box)
108}
109
110private func argvFrom(_ url: URL) -> [String] {
111 URLComponents(url: url, resolvingAgainstBaseURL: false)?
112 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
113}
114
115private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
116 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
117 return try #require(body["argv"] as? [String])
118}
119
120private func decode(_ json: String) throws -> Notification {
121 let decoder = JSONDecoder()
122 decoder.dateDecodingStrategy = .iso8601
123 return try decoder.decode(Notification.self, from: Data(json.utf8))
124}
125
126private let unreadBuild = """
127 {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
128 "summary":"build 966 failed","path":"krz/gitbay/builds/966",\
129 "created_at":"2026-09-06T22:14:46Z"}
130 """
131
132struct NotificationDecodingTests {
133
134 /// read_at is omitempty; absent means unread, and that is the common
135 /// case for the default listing.
136 @Test func anAbsentReadAtMeansUnread() throws {
137 let n = try decode(unreadBuild)
138 #expect(n.readAt == nil)
139 #expect(n.isUnread)
140 }
141
142 @Test func aPresentReadAtMeansRead() throws {
143 let n = try decode("""
144 {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
145 "summary":"x","path":"krz/gitbay/builds/966",\
146 "created_at":"2026-09-06T22:14:46Z","read_at":"2026-09-06T22:20:24Z"}
147 """)
148 #expect(n.readAt != nil)
149 #expect(n.isUnread == false)
150 }
151
152 @Test func everyKindRoutesToItsScreen() throws {
153 let issue = try decode("""
154 {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
155 "path":"krz/gitbay/issues/168","created_at":"2026-09-06T22:14:46Z"}
156 """)
157 #expect(issue.destination == .issue(repo: "krz/gitbay", number: 168))
158
159 // The segment is "mrs", not "merge_requests".
160 let mr = try decode("""
161 {"id":2,"repo":"krz/gitbay","kind":"mr","actor":"cmc","summary":"x",\
162 "path":"krz/gitbay/mrs/282","created_at":"2026-09-06T22:14:46Z"}
163 """)
164 #expect(mr.destination == .mr(repo: "krz/gitbay", number: 282))
165
166 let build = try decode(unreadBuild)
167 #expect(build.destination == .build(repo: "krz/gitbay", number: 966))
168 }
169
170 /// A path the app cannot route must yield nil, not a crash and not a
171 /// wrong destination.
172 @Test func anUnroutablePathIsNil() throws {
173 for path in ["krz/gitbay/wiki/Home", "krz/gitbay/issues", "krz/gitbay/issues/notanumber",
174 "too/short", "krz/gitbay/issues/1/extra", ""] {
175 let n = try decode("""
176 {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
177 "path":"\(path)","created_at":"2026-09-06T22:14:46Z"}
178 """)
179 #expect(n.destination == nil, "\(path) should not route")
180 }
181 }
182
183 /// The repo comes from the path, not the `repo` field, so a
184 /// mismatch cannot send the user to the wrong repository.
185 @Test func theDestinationRepoComesFromThePath() throws {
186 let n = try decode("""
187 {"id":1,"repo":"other/repo","kind":"issue","actor":"cmc","summary":"x",\
188 "path":"krz/gitbay/issues/7","created_at":"2026-09-06T22:14:46Z"}
189 """)
190 #expect(n.destination == .issue(repo: "krz/gitbay", number: 7))
191 }
192}
193
194@MainActor
195struct NotificationsViewModelTests {
196
197 private let page = """
198 {"protocol_version":1,"data":{"items":[\(unreadBuild)]},"exit_code":0}
199 """
200
201 @Test func theDefaultListingIsUnreadOnly() async throws {
202 let (client, stub) = try makeClient()
203 stub.enqueue(.init(status: 200, json: page))
204 let model = NotificationsViewModel(client: client)
205 await model.load()
206
207 let argv = argvFrom(try #require(stub.seen.last).url)
208 #expect(argv.prefix(2) == ["notifications", "list"])
209 #expect(argv.contains("--all") == false)
210 #expect(model.state.value?.count == 1)
211 }
212
213 @Test func showingAllAddsTheFlag() async throws {
214 let (client, stub) = try makeClient()
215 stub.enqueue(.init(status: 200, json: page))
216 let model = NotificationsViewModel(client: client)
217 await model.load()
218
219 stub.enqueue(.init(status: 200, json: page))
220 model.showAll = true
221 try await Task.sleep(for: .milliseconds(150))
222
223 #expect(argvFrom(try #require(stub.seen.last).url).contains("--all"))
224 }
225
226 @Test func markingOneReadSendsItsId() async throws {
227 let (client, stub) = try makeClient()
228 stub.enqueue(.init(status: 200, json: page))
229 let model = NotificationsViewModel(client: client)
230 await model.load()
231
232 stub.enqueue(.init(status: 200, json: """
233 {"protocol_version":1,"exit_code":0}
234 """))
235 stub.enqueue(.init(status: 200, json: page))
236 await model.markRead(15)
237
238 let write = try #require(stub.seen.first { $0.method == "POST" })
239 #expect(try argvOf(write) == ["notifications", "read", "15"])
240 }
241
242 @Test func markingAllReadUsesTheFlagNotAList() async throws {
243 let (client, stub) = try makeClient()
244 stub.enqueue(.init(status: 200, json: page))
245 let model = NotificationsViewModel(client: client)
246 await model.load()
247
248 stub.enqueue(.init(status: 200, json: """
249 {"protocol_version":1,"exit_code":0}
250 """))
251 stub.enqueue(.init(status: 200, json: page))
252 await model.markAllRead()
253
254 let write = try #require(stub.seen.first { $0.method == "POST" })
255 #expect(try argvOf(write) == ["notifications", "read", "--all"])
256 }
257
258 @Test func anEmptyInboxIsAnEmptyStateNotAFailure() async throws {
259 let (client, stub) = try makeClient()
260 stub.enqueue(.init(status: 200, json: """
261 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
262 """))
263 let model = NotificationsViewModel(client: client)
264 await model.load()
265
266 guard case .empty = model.state else {
267 Testing.Issue.record("expected empty, got \(model.state)")
268 return
269 }
270 }
271
272 @Test func aRefusalSurfacesAndDoesNotReload() async throws {
273 let (client, stub) = try makeClient()
274 stub.enqueue(.init(status: 200, json: page))
275 let model = NotificationsViewModel(client: client)
276 await model.load()
277 let before = stub.seen.count
278
279 stub.enqueue(.init(status: 200, json: """
280 {"protocol_version":1,"error":"denied","exit_code":4}
281 """))
282 await model.markRead(15)
283
284 #expect(model.actionError?.isEmpty == false)
285 #expect(model.working == false)
286 // One POST, no reload after a refusal.
287 #expect(stub.seen.count == before + 1)
288 }
289}
290```
291
292- [ ] **Step 2: Run to verify failure.**
293- [ ] **Step 3: Implement.** Follow `IssueListViewModel` for the `PagedListModel` shape and `RepoSettingsViewModel.perform(argv:)` for the write half (copy it; do not extract a shared helper — that duplication is a standing decision in this codebase).
294- [ ] **Step 4: Run the tests.**
295- [ ] **Step 5: Commit** — `git commit -m "Notification inbox model and view model"`
296
297---
298
299### Task 2: The screen, the badge and the route
300
301**Files:** Create `gitbay/Views/Account/NotificationsView.swift`; modify `DashboardModels.swift`, `DashboardView.swift`, `RepoRoute.swift`, `ContentView.swift`
302
303**The badge is free.** `dashboard` already emits `unread: int` — the server comments it as existing "so a client showing one does not need a second read to fill it" (`internal/control/dashboard.go:78`). iOS does not decode it yet.
304
305**`DashboardData` has a hand-written `init(from:)`** (`DashboardModels.swift:27-37`) that decodes each field with `decodeIfPresent ?? []`. Adding `unread` means editing that initialiser — check field by field that you drop nothing, the same care MR 4's `MRDetail` initialiser needed. Decode it as `decodeIfPresent(Int.self, forKey: .unread) ?? 0`, since a server predating the field would omit it.
306
307**The screen:** a `List` over the paged notifications with `PageFooter`, a toggle for unread-only versus all, a "Mark all read" action, and per-row swipe-to-mark-read. An unread row should read as unread — the web uses the count, so weight or a dot is enough; do not invent a colour that means nothing elsewhere in the app.
308
309Each row shows `actor`, `summary` and the relative `createdAt`, and navigates to `destination` when it is non-nil. A row whose destination is `nil` renders plainly and is not tappable.
310
311**Entry point:** a toolbar bell on the Dashboard carrying the unread count, matching where the web puts it (its rail). `DashboardView` already has a `.toolbar` with a `.topBarTrailing` item — add beside it.
312
313Add `RepoRoute.notifications` and its `ContentView` destination.
314
315- [ ] **Step 1: Decode `unread` in `DashboardData`**
316- [ ] **Step 2: `NotificationsView`**
317- [ ] **Step 3: Route case + `ContentView` destination**
318- [ ] **Step 4: Dashboard bell with the count**
319- [ ] **Step 5: Build and run the full suite.** No drop.
320- [ ] **Step 6: Commit** — `git commit -m "Notification inbox screen and dashboard badge"`
321
322---
323
324### Task 3: Flip the parity row and open the merge request
325
326- [ ] **Step 1:** In the Accounts table, `notification inbox` goes to `yes` for iOS. Touch no other row.
327
328Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff`. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch.
329
330- [ ] **Step 2:** Run the full suite; record the real number.
331- [ ] **Step 3:** Open the merge request.
332
333---
334
335## Notes for whoever executes this
336
337**`notifications list` IS paginated.** Use `PagedListModel`. This differs from `search` in MR 3, which is not.
338
339**The merge request path segment is `mrs`.** Not `merge_requests`. The wrong spelling gives a silently untappable row.
340
341**Absent `read_at` means unread**, and it is the common case. Do not require the key.
342
343**Take the repo from the path, not the `repo` field** — a test pins this, so a row can never send the user to a different repository than the one its link names.
344
345**The badge needs no extra request.** If you find yourself adding a `notifications list` call to fill the Dashboard count, stop: `dashboard` already carries it.
gitbay/Account/NotificationsViewModel.swift added +119
@@ -0,0 +1,119 @@
1import Foundation
2import Observation
3
4/// One row of `notifications list`. `read_at` is omitempty: absent means
5/// unread, which is the common case for the default (unread-only) listing.
6nonisolated struct InboxNotification: Decodable, Sendable, Hashable, Identifiable {
7 let id: Int64
8 let repo: String
9 let kind: String
10 let actor: String
11 let summary: String
12 let path: String
13 let createdAt: Date
14 let readAt: Date?
15
16 enum CodingKeys: String, CodingKey {
17 case id, repo, kind, actor, summary, path
18 case createdAt = "created_at"
19 case readAt = "read_at"
20 }
21
22 var isUnread: Bool { readAt == nil }
23
24 /// Where this notification points, derived from `path` — a
25 /// web-style route, `<owner>/<repo>/<section>/<n>` — not from `kind`
26 /// or the `repo` field. A shape the app cannot route (wrong section,
27 /// missing or non-numeric id, wrong number of components) yields
28 /// nil, which a row renders as untappable rather than crashing.
29 var destination: NotificationDestination? {
30 let parts = path.split(separator: "/", omittingEmptySubsequences: false)
31 guard parts.count == 4, let number = Int64(parts[3]) else { return nil }
32 let repo = "\(parts[0])/\(parts[1])"
33 switch parts[2] {
34 case "issues": return .issue(repo: repo, number: number)
35 case "mrs": return .mr(repo: repo, number: number)
36 case "builds": return .build(repo: repo, number: number)
37 default: return nil
38 }
39 }
40}
41
42/// The screen a notification's `destination` opens. The merge request
43/// section is `mrs` on the wire, never `merge_requests`.
44nonisolated enum NotificationDestination: Hashable, Sendable {
45 case issue(repo: String, number: Int64)
46 case mr(repo: String, number: Int64)
47 case build(repo: String, number: Int64)
48}
49
50/// The notification inbox: `notifications list [--all]`, paginated, plus
51/// `notifications read`.
52@Observable
53@MainActor
54final class NotificationsViewModel {
55
56 let list: PagedListModel<InboxNotification>
57 /// Off shows unread only, the default listing; on adds `--all`. A
58 /// segmented control is quick to tap repeatedly, so a stale reload
59 /// from an earlier tap must not land after a later one.
60 var showAll = false {
61 didSet {
62 guard showAll != oldValue else { return }
63 actionError = nil
64 configureList()
65 reloadTask?.cancel()
66 reloadTask = Task { await list.reload() }
67 }
68 }
69
70 private(set) var actionError: String?
71 private(set) var working = false
72
73 private let client: GitbayClient
74 private var reloadTask: Task<Void, Never>?
75
76 init(client: GitbayClient) {
77 self.client = client
78 list = PagedListModel(
79 client: client,
80 argv: ["notifications", "list"],
81 emptyMessage: "No notifications."
82 )
83 }
84
85 var state: LoadState<[InboxNotification]> { list.state }
86
87 func load() async {
88 reloadTask?.cancel()
89 actionError = nil
90 await list.reload()
91 }
92
93 func markRead(_ id: Int64) async {
94 guard !working else { return }
95 await perform(["notifications", "read", String(id)])
96 }
97
98 func markAllRead() async {
99 await perform(["notifications", "read", "--all"])
100 }
101
102 private func configureList() {
103 list.argv = ["notifications", "list"] + (showAll ? ["--all"] : [])
104 }
105
106 private func perform(_ argv: [String]) async {
107 working = true
108 actionError = nil
109 defer { working = false }
110 do {
111 try await client.run(argv)
112 await load()
113 } catch let error as GitbayError {
114 actionError = error.userFacingMessage
115 } catch {
116 actionError = GitbayError.transport(error).userFacingMessage
117 }
118 }
119}
gitbay/ContentView.swift +2
@@ -114,6 +114,8 @@ private struct RouteDestinations: ViewModifier {
114114 AccountView(client: client)
115115 case .addAccount:
116116 SignInView()
117 case .notifications:
118 NotificationsView(client: client)
117119 }
118120 }
119121
gitbay/Dashboard/DashboardModels.swift +5 −1
@@ -14,9 +14,12 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable {
1414 /// Retained by the CLI for compatibility; builds are not a web
1515 /// dashboard section.
1616 let builds: [DashboardBuild]
17 /// The unread notification count, so a client showing a badge does
18 /// not need a second read to fill it.
19 let unread: Int
1720
1821 enum CodingKeys: String, CodingKey {
19 case pinned, builds
22 case pinned, builds, unread
2023 case reviewQueue = "review_queue"
2124 case openMRs = "open_mrs"
2225 case assignedIssues = "assigned_issues"
@@ -33,6 +36,7 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable {
3336 pinned = try values.decodeIfPresent([RepoSummary].self, forKey: .pinned) ?? []
3437 recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? []
3538 builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? []
39 unread = try values.decodeIfPresent(Int.self, forKey: .unread) ?? 0
3640 }
3741}
3842
gitbay/Views/Account/NotificationsView.swift added +103
@@ -0,0 +1,103 @@
1import SwiftUI
2
3/// The notification inbox: `notifications list [--all]`, paginated, with
4/// a switch between the default unread-only listing and everything.
5struct NotificationsView: View {
6
7 @State private var model: NotificationsViewModel
8
9 init(client: GitbayClient) {
10 _model = State(initialValue: NotificationsViewModel(client: client))
11 }
12
13 var body: some View {
14 List {
15 Picker("Filter", selection: Bindable(model).showAll) {
16 Text("Unread").tag(false)
17 Text("All").tag(true)
18 }
19 .pickerStyle(.segmented)
20 .listRowBackground(Color.clear)
21 .listRowInsets(EdgeInsets())
22
23 if let actionError = model.actionError {
24 Section { GBNotice(actionError) }
25 }
26
27 ForEach(model.state.value ?? []) { notification in
28 NotificationLink(notification: notification)
29 .swipeActions {
30 if notification.isUnread {
31 Button("Mark read") {
32 Task { await model.markRead(notification.id) }
33 }
34 .tint(.gbAccent)
35 }
36 }
37 }
38 PageFooter(list: model.list)
39 }
40 .overlay { LoadStateOverlay(state: model.state) }
41 .navigationTitle("Notifications")
42 .navigationBarTitleDisplayMode(.inline)
43 .toolbar {
44 ToolbarItem(placement: .topBarTrailing) {
45 Button("Mark all read") {
46 Task { await model.markAllRead() }
47 }
48 .disabled(model.working)
49 }
50 }
51 .task { await model.load() }
52 .refreshable { await model.load() }
53 }
54}
55
56/// Routes to `destination` when the path resolves to a screen; a
57/// notification the app cannot route renders plainly and is not
58/// tappable.
59private struct NotificationLink: View {
60 let notification: InboxNotification
61
62 @ViewBuilder
63 var body: some View {
64 switch notification.destination {
65 case .issue(let repo, let number):
66 NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) {
67 NotificationRow(notification: notification)
68 }
69 case .mr(let repo, let number):
70 NavigationLink(value: MRRoute.mr(repo: repo, number: number)) {
71 NotificationRow(notification: notification)
72 }
73 case .build(let repo, let number):
74 NavigationLink(value: BuildRoute.detail(repo: repo, number: number)) {
75 NotificationRow(notification: notification)
76 }
77 case nil:
78 NotificationRow(notification: notification)
79 }
80 }
81}
82
83private struct NotificationRow: View {
84 let notification: InboxNotification
85
86 var body: some View {
87 VStack(alignment: .leading, spacing: 4) {
88 HStack(alignment: .firstTextBaseline) {
89 Text(notification.actor)
90 .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular))
91 Spacer()
92 Text(notification.createdAt, format: .relative(presentation: .named))
93 .font(.gbSans(.caption))
94 .foregroundStyle(.secondary)
95 }
96 Text(notification.summary)
97 .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular))
98 .foregroundStyle(notification.isUnread ? .primary : .secondary)
99 .lineLimit(2)
100 }
101 .padding(.vertical, 2)
102 }
103}
gitbay/Views/Dashboard/DashboardView.swift +18
@@ -44,6 +44,19 @@ struct DashboardView: View {
4444 .overlay { LoadStateOverlay(state: model.state) }
4545 .navigationTitle("Dashboard")
4646 .toolbar {
47 ToolbarItem(placement: .topBarTrailing) {
48 NavigationLink(value: RepoRoute.notifications) {
49 HStack(spacing: 2) {
50 Image(systemName: "bell")
51 if let unread = model.state.value?.unread, unread > 0 {
52 Text(String(unread))
53 .font(.gbSans(.caption2))
54 }
55 }
56 }
57 .accessibilityIdentifier("dashboard-notifications-button")
58 .accessibilityLabel(bellLabel)
59 }
4760 ToolbarItem(placement: .topBarTrailing) {
4861 Button {
4962 composing = true
@@ -66,6 +79,11 @@ struct DashboardView: View {
6679 .refreshable { await model.load() }
6780 }
6881
82 private var bellLabel: String {
83 guard let unread = model.state.value?.unread, unread > 0 else { return "Notifications" }
84 return "Notifications, \(unread) unread"
85 }
86
6987 private func itemSection(
7088 _ title: String,
7189 items: [DashboardItem],
gitbay/Views/Repos/RepoRoute.swift +1
@@ -20,4 +20,5 @@ nonisolated enum RepoRoute: Hashable {
2020 case profile(String)
2121 case account
2222 case addAccount
23 case notifications
2324}
gitbayTests/DashboardViewModelTests.swift +3 −1
@@ -27,7 +27,8 @@ private let dashboardJSON = """
2727 "data":{"number":2},"created_at":"2026-08-27T10:30:00.000Z"}],\
2828 "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\
2929 "sha":"65ba14e0000000000000","ref":"refs/heads/main",\
30 "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}]},\
30 "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}],\
31 "unread":3},\
3132 "exit_code":0}
3233 """
3334
@@ -54,6 +55,7 @@ struct DashboardViewModelTests {
5455 #expect(data.openIssues.first?.number == 11)
5556 #expect(data.recentActivity.first?.phrase == "merged !2")
5657 #expect(data.builds.first?.status == "success")
58 #expect(data.unread == 3)
5759 // The whole screen cost exactly one request.
5860 #expect(stub.seen.count == 1)
5961 #expect(stub.seen.first?.url.query() == "argv=dashboard")
gitbayTests/NotificationTests.swift added +215
@@ -0,0 +1,215 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvFrom(_ url: URL) -> [String] {
16 URLComponents(url: url, resolvingAgainstBaseURL: false)?
17 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
18}
19
20private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
21 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
22 return try #require(body["argv"] as? [String])
23}
24
25private func decode(_ json: String) throws -> InboxNotification {
26 let decoder = JSONDecoder()
27 decoder.dateDecodingStrategy = .iso8601
28 return try decoder.decode(InboxNotification.self, from: Data(json.utf8))
29}
30
31private let unreadBuild = """
32 {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
33 "summary":"build 966 failed","path":"krz/gitbay/builds/966",\
34 "created_at":"2026-09-06T22:14:46.000Z"}
35 """
36
37struct NotificationDecodingTests {
38
39 /// read_at is omitempty; absent means unread, and that is the common
40 /// case for the default listing.
41 @Test func anAbsentReadAtMeansUnread() throws {
42 let n = try decode(unreadBuild)
43 #expect(n.readAt == nil)
44 #expect(n.isUnread)
45 }
46
47 @Test func aPresentReadAtMeansRead() throws {
48 let n = try decode("""
49 {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
50 "summary":"x","path":"krz/gitbay/builds/966",\
51 "created_at":"2026-09-06T22:14:46.000Z","read_at":"2026-09-06T22:20:24.000Z"}
52 """)
53 #expect(n.readAt != nil)
54 #expect(n.isUnread == false)
55 }
56
57 @Test func everyKindRoutesToItsScreen() throws {
58 let issue = try decode("""
59 {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
60 "path":"krz/gitbay/issues/168","created_at":"2026-09-06T22:14:46.000Z"}
61 """)
62 #expect(issue.destination == .issue(repo: "krz/gitbay", number: 168))
63
64 // The segment is "mrs", not "merge_requests".
65 let mr = try decode("""
66 {"id":2,"repo":"krz/gitbay","kind":"mr","actor":"cmc","summary":"x",\
67 "path":"krz/gitbay/mrs/282","created_at":"2026-09-06T22:14:46.000Z"}
68 """)
69 #expect(mr.destination == .mr(repo: "krz/gitbay", number: 282))
70
71 let build = try decode(unreadBuild)
72 #expect(build.destination == .build(repo: "krz/gitbay", number: 966))
73 }
74
75 /// A path the app cannot route must yield nil, not a crash and not a
76 /// wrong destination.
77 @Test func anUnroutablePathIsNil() throws {
78 for path in ["krz/gitbay/wiki/Home", "krz/gitbay/issues", "krz/gitbay/issues/notanumber",
79 "too/short", "krz/gitbay/issues/1/extra", ""] {
80 let n = try decode("""
81 {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
82 "path":"\(path)","created_at":"2026-09-06T22:14:46.000Z"}
83 """)
84 #expect(n.destination == nil, "\(path) should not route")
85 }
86 }
87
88 /// The repo comes from the path, not the `repo` field, so a
89 /// mismatch cannot send the user to the wrong repository.
90 @Test func theDestinationRepoComesFromThePath() throws {
91 let n = try decode("""
92 {"id":1,"repo":"other/repo","kind":"issue","actor":"cmc","summary":"x",\
93 "path":"krz/gitbay/issues/7","created_at":"2026-09-06T22:14:46.000Z"}
94 """)
95 #expect(n.destination == .issue(repo: "krz/gitbay", number: 7))
96 }
97}
98
99@MainActor
100struct NotificationsViewModelTests {
101
102 private let page = """
103 {"protocol_version":1,"data":{"items":[\(unreadBuild)]},"exit_code":0}
104 """
105
106 private let pageWithCursor = """
107 {"protocol_version":1,"data":{"items":[\(unreadBuild)],"next":"c1"},"exit_code":0}
108 """
109
110 @Test func theDefaultListingIsUnreadOnly() async throws {
111 let (client, stub) = try makeClient()
112 stub.enqueue(.init(status: 200, json: page))
113 let model = NotificationsViewModel(client: client)
114 await model.load()
115
116 let argv = argvFrom(try #require(stub.seen.last).url)
117 #expect(argv.prefix(2) == ["notifications", "list"])
118 #expect(argv.contains("--all") == false)
119 #expect(model.state.value?.count == 1)
120 }
121
122 @Test func showingAllAddsTheFlag() async throws {
123 let (client, stub) = try makeClient()
124 stub.enqueue(.init(status: 200, json: page))
125 let model = NotificationsViewModel(client: client)
126 await model.load()
127
128 stub.enqueue(.init(status: 200, json: page))
129 model.showAll = true
130 try await Task.sleep(for: .milliseconds(150))
131
132 #expect(argvFrom(try #require(stub.seen.last).url).contains("--all"))
133 }
134
135 /// A filter change reloads rather than pages, so it must not carry
136 /// forward a cursor from whatever page was on screen.
137 @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws {
138 let (client, stub) = try makeClient()
139 stub.enqueue(.init(status: 200, json: pageWithCursor))
140 let model = NotificationsViewModel(client: client)
141 await model.load()
142 #expect(model.list.hasMore)
143
144 stub.enqueue(.init(status: 200, json: page))
145 model.showAll = true
146 try await Task.sleep(for: .milliseconds(150))
147
148 let argv = argvFrom(try #require(stub.seen.last).url)
149 #expect(argv.contains("--cursor") == false)
150 }
151
152 @Test func markingOneReadSendsItsId() async throws {
153 let (client, stub) = try makeClient()
154 stub.enqueue(.init(status: 200, json: page))
155 let model = NotificationsViewModel(client: client)
156 await model.load()
157
158 stub.enqueue(.init(status: 200, json: """
159 {"protocol_version":1,"exit_code":0}
160 """))
161 stub.enqueue(.init(status: 200, json: page))
162 await model.markRead(15)
163
164 let write = try #require(stub.seen.first { $0.method == "POST" })
165 #expect(try argvOf(write) == ["notifications", "read", "15"])
166 }
167
168 @Test func markingAllReadUsesTheFlagNotAList() async throws {
169 let (client, stub) = try makeClient()
170 stub.enqueue(.init(status: 200, json: page))
171 let model = NotificationsViewModel(client: client)
172 await model.load()
173
174 stub.enqueue(.init(status: 200, json: """
175 {"protocol_version":1,"exit_code":0}
176 """))
177 stub.enqueue(.init(status: 200, json: page))
178 await model.markAllRead()
179
180 let write = try #require(stub.seen.first { $0.method == "POST" })
181 #expect(try argvOf(write) == ["notifications", "read", "--all"])
182 }
183
184 @Test func anEmptyInboxIsAnEmptyStateNotAFailure() async throws {
185 let (client, stub) = try makeClient()
186 stub.enqueue(.init(status: 200, json: """
187 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
188 """))
189 let model = NotificationsViewModel(client: client)
190 await model.load()
191
192 guard case .empty = model.state else {
193 Testing.Issue.record("expected empty, got \(model.state)")
194 return
195 }
196 }
197
198 @Test func aRefusalSurfacesAndDoesNotReload() async throws {
199 let (client, stub) = try makeClient()
200 stub.enqueue(.init(status: 200, json: page))
201 let model = NotificationsViewModel(client: client)
202 await model.load()
203 let before = stub.seen.count
204
205 stub.enqueue(.init(status: 200, json: """
206 {"protocol_version":1,"error":"denied","exit_code":4}
207 """))
208 await model.markRead(15)
209
210 #expect(model.actionError?.isEmpty == false)
211 #expect(model.working == false)
212 // One POST, no reload after a refusal.
213 #expect(stub.seen.count == before + 1)
214 }
215}