Commit e576103c5e
Unsigned
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 | ``` | |
| 24 | notifications list [--all] [--limit <n>] [--cursor <c>] | |
| 25 | notifications 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 | ||
| 39 | Everything 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 | |
| 44 | grep -cE "^Test case '[^']*' passed" /tmp/out.txt | |
| 45 | grep -cE "^Test case '[^']*' failed" /tmp/out.txt | |
| 46 | grep -cE "^Test case '[^']*' skipped" /tmp/out.txt | |
| 47 | grep -rhoE "@Test(\([^)]*\))? func" gitbayTests/*.swift | wc -l | |
| 48 | ``` | |
| 49 | ||
| 50 | `declared - skipped == passed`. One test is always skipped (`LiveInstanceTests`). | |
| 51 | ||
| 52 | Baseline: **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 | ||
| 89 | Parse 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 | ||
| 93 | Create `gitbayTests/NotificationTests.swift`: | |
| 94 | ||
| 95 | ```swift | |
| 96 | import Foundation | |
| 97 | import Testing | |
| 98 | @testable import gitbay | |
| 99 | ||
| 100 | private 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 | ||
| 110 | private func argvFrom(_ url: URL) -> [String] { | |
| 111 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | |
| 112 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | |
| 113 | } | |
| 114 | ||
| 115 | private 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 | ||
| 120 | private 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 | ||
| 126 | private 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 | ||
| 132 | struct 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 | |
| 195 | struct 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 | ||
| 309 | Each 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 | ||
| 313 | Add `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 | ||
| 328 | Land 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 6 | nonisolated 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`. | |
| 44 | nonisolated 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 | |
| 54 | final 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 { | ||
| 114 | 114 | AccountView(client: client) |
| 115 | 115 | case .addAccount: |
| 116 | 116 | SignInView() |
| 117 | case .notifications: | |
| 118 | NotificationsView(client: client) | |
| 117 | 119 | } |
| 118 | 120 | } |
| 119 | 121 | |
gitbay/Dashboard/DashboardModels.swift +5 −1
| @@ -14,9 +14,12 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable { | ||
| 14 | 14 | /// Retained by the CLI for compatibility; builds are not a web |
| 15 | 15 | /// dashboard section. |
| 16 | 16 | 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 | |
| 17 | 20 | |
| 18 | 21 | enum CodingKeys: String, CodingKey { |
| 19 | case pinned, builds | |
| 22 | case pinned, builds, unread | |
| 20 | 23 | case reviewQueue = "review_queue" |
| 21 | 24 | case openMRs = "open_mrs" |
| 22 | 25 | case assignedIssues = "assigned_issues" |
| @@ -33,6 +36,7 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable { | ||
| 33 | 36 | pinned = try values.decodeIfPresent([RepoSummary].self, forKey: .pinned) ?? [] |
| 34 | 37 | recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? [] |
| 35 | 38 | builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? [] |
| 39 | unread = try values.decodeIfPresent(Int.self, forKey: .unread) ?? 0 | |
| 36 | 40 | } |
| 37 | 41 | } |
| 38 | 42 | |
gitbay/Views/Account/NotificationsView.swift added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// The notification inbox: `notifications list [--all]`, paginated, with | |
| 4 | /// a switch between the default unread-only listing and everything. | |
| 5 | struct 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. | |
| 59 | private 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 | ||
| 83 | private 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 { | ||
| 44 | 44 | .overlay { LoadStateOverlay(state: model.state) } |
| 45 | 45 | .navigationTitle("Dashboard") |
| 46 | 46 | .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 | } | |
| 47 | 60 | ToolbarItem(placement: .topBarTrailing) { |
| 48 | 61 | Button { |
| 49 | 62 | composing = true |
| @@ -66,6 +79,11 @@ struct DashboardView: View { | ||
| 66 | 79 | .refreshable { await model.load() } |
| 67 | 80 | } |
| 68 | 81 | |
| 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 | ||
| 69 | 87 | private func itemSection( |
| 70 | 88 | _ title: String, |
| 71 | 89 | items: [DashboardItem], |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -20,4 +20,5 @@ nonisolated enum RepoRoute: Hashable { | ||
| 20 | 20 | case profile(String) |
| 21 | 21 | case account |
| 22 | 22 | case addAccount |
| 23 | case notifications | |
| 23 | 24 | } |
gitbayTests/DashboardViewModelTests.swift +3 −1
| @@ -27,7 +27,8 @@ private let dashboardJSON = """ | ||
| 27 | 27 | "data":{"number":2},"created_at":"2026-08-27T10:30:00.000Z"}],\ |
| 28 | 28 | "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\ |
| 29 | 29 | "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},\ | |
| 31 | 32 | "exit_code":0} |
| 32 | 33 | """ |
| 33 | 34 | |
| @@ -54,6 +55,7 @@ struct DashboardViewModelTests { | ||
| 54 | 55 | #expect(data.openIssues.first?.number == 11) |
| 55 | 56 | #expect(data.recentActivity.first?.phrase == "merged !2") |
| 56 | 57 | #expect(data.builds.first?.status == "success") |
| 58 | #expect(data.unread == 3) | |
| 57 | 59 | // The whole screen cost exactly one request. |
| 58 | 60 | #expect(stub.seen.count == 1) |
| 59 | 61 | #expect(stub.seen.first?.url.query() == "argv=dashboard") |
gitbayTests/NotificationTests.swift added +215
| @@ -0,0 +1,215 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private func argvFrom(_ url: URL) -> [String] { | |
| 16 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | |
| 17 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | |
| 18 | } | |
| 19 | ||
| 20 | private 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 | ||
| 25 | private 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 | ||
| 31 | private 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 | ||
| 37 | struct 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 | |
| 100 | struct 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 | } | |