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 {
54final class NotificationsViewModel { 54final class NotificationsViewModel {
55 55
56 let list: PagedListModel<InboxNotification> 56 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.
58 var showAll = false { 60 var showAll = false {
59 didSet { 61 didSet {
60 guard showAll != oldValue else { return } 62 guard showAll != oldValue else { return }
61 configureList() 63 configureList()
62 Task { await list.reload() } 64 reloadTask?.cancel()
65 reloadTask = Task { await list.reload() }
63 } 66 }
64 } 67 }
65 68
@@ -67,6 +70,7 @@ final class NotificationsViewModel {
67 private(set) var working = false 70 private(set) var working = false
68 71
69 private let client: GitbayClient 72 private let client: GitbayClient
73 private var reloadTask: Task<Void, Never>?
70 74
71 init(client: GitbayClient) { 75 init(client: GitbayClient) {
72 self.client = client 76 self.client = client
@@ -80,6 +84,7 @@ final class NotificationsViewModel {
80 var state: LoadState<[InboxNotification]> { list.state } 84 var state: LoadState<[InboxNotification]> { list.state }
81 85
82 func load() async { 86 func load() async {
87 reloadTask?.cancel()
83 await list.reload() 88 await list.reload()
84 } 89 }
85 90
gitbay/ContentView.swift +2
@@ -114,6 +114,8 @@ private struct RouteDestinations: ViewModifier {
114 AccountView(client: client) 114 AccountView(client: client)
115 case .addAccount: 115 case .addAccount:
116 SignInView() 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 /// Retained by the CLI for compatibility; builds are not a web 14 /// Retained by the CLI for compatibility; builds are not a web
15 /// dashboard section. 15 /// dashboard section.
16 let builds: [DashboardBuild] 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 enum CodingKeys: String, CodingKey { 21 enum CodingKeys: String, CodingKey {
19 case pinned, builds 22 case pinned, builds, unread
20 case reviewQueue = "review_queue" 23 case reviewQueue = "review_queue"
21 case openMRs = "open_mrs" 24 case openMRs = "open_mrs"
22 case assignedIssues = "assigned_issues" 25 case assignedIssues = "assigned_issues"
@@ -33,6 +36,7 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable {
33 pinned = try values.decodeIfPresent([RepoSummary].self, forKey: .pinned) ?? [] 36 pinned = try values.decodeIfPresent([RepoSummary].self, forKey: .pinned) ?? []
34 recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? [] 37 recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? []
35 builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? [] 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 @@
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 {
44 .overlay { LoadStateOverlay(state: model.state) } 44 .overlay { LoadStateOverlay(state: model.state) }
45 .navigationTitle("Dashboard") 45 .navigationTitle("Dashboard")
46 .toolbar { 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 }
47 ToolbarItem(placement: .topBarTrailing) { 59 ToolbarItem(placement: .topBarTrailing) {
48 Button { 60 Button {
49 composing = true 61 composing = true
gitbay/Views/Repos/RepoRoute.swift +1
@@ -20,4 +20,5 @@ nonisolated enum RepoRoute: Hashable {
20 case profile(String) 20 case profile(String)
21 case account 21 case account
22 case addAccount 22 case addAccount
23 case notifications
23} 24}