Commit f321cd5b62

f321cd5b62a129d7fde0b234fe9671e7c6583ea3

parent: 1f38e9b6ff

Verified · cmc

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

Notification inbox screen and dashboard badge

Layout: unified · split

gitbay/Account/NotificationsViewModel.swift +7 −2
@@ -54,12 +54,15 @@ nonisolated enum NotificationDestination: Hashable, Sendable {
5454final class NotificationsViewModel {
5555
5656 let list: PagedListModel<InboxNotification>
57 /// Off shows unread only, the default listing; on adds `--all`.
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.
5860 var showAll = false {
5961 didSet {
6062 guard showAll != oldValue else { return }
6163 configureList()
62 Task { await list.reload() }
64 reloadTask?.cancel()
65 reloadTask = Task { await list.reload() }
6366 }
6467 }
6568
@@ -67,6 +70,7 @@ final class NotificationsViewModel {
6770 private(set) var working = false
6871
6972 private let client: GitbayClient
73 private var reloadTask: Task<Void, Never>?
7074
7175 init(client: GitbayClient) {
7276 self.client = client
@@ -80,6 +84,7 @@ final class NotificationsViewModel {
8084 var state: LoadState<[InboxNotification]> { list.state }
8185
8286 func load() async {
87 reloadTask?.cancel()
8388 await list.reload()
8489 }
8590
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 +12
@@ -44,6 +44,18 @@ 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 }
4759 ToolbarItem(placement: .topBarTrailing) {
4860 Button {
4961 composing = true
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}