Commit 302a2d2e7e

302a2d2e7e9e275d1d31ec7ab5ab8a172651f2e4

parent: 1d894ad72b

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 05:00 UTC

feat: add a ticket activity feed

todo.sr.ht's root events query returns what the authenticated user is
subscribed to or implicated in, newest first, across every tracker including
ones they do not own. It was never called, so the only way to notice a reply was
to open the ticket.

Reuses EventChange, which already decodes todo's polymorphic EventDetail for
ticket timelines, so the same inline fragments describe both surfaces. Rows push
straight to the ticket.

events is nullable and comes back null when the token lacks the EVENTS scope, so
that case reports a scope problem rather than an empty feed, which would read as
"nothing has happened".

Paginates rather than fetching everything: an active account's history is
unbounded and the top of it is the whole point.

archiveMessage, the other half of this roadmap item, is not here. It is marked
@internal in the schema and inaccessible, like revokePersonalAccessToken already
recorded in SCOPE.md.

Layout: unified · split

Hutch/App/RootView.swift +3
@@ -439,6 +439,7 @@ enum MoreRoute: Hashable {
439439 case projectDashboard(id: String, title: String?)
440440 case mailingList(InboxMailingListReference)
441441 case thread(InboxThreadSummary)
442 case activity
442443 case manPageBrowser
443444 case manPage(URL)
444445}
@@ -472,6 +473,8 @@ private struct MoreNavigationRoot: View {
472473 ProjectDashboardDeepLinkView(projectID: id, title: title)
473474 case .mailingList(let mailingList):
474475 MailingListDetailView(mailingList: mailingList)
476 case .activity:
477 ActivityView()
475478 case .thread(let thread):
476479 ThreadDetailView(
477480 thread: thread,
Hutch/Views/Activity/ActivityView.swift added +96
@@ -0,0 +1,96 @@
1import SwiftUI
2
3struct ActivityView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: ActivityViewModel?
6
7 var body: some View {
8 Group {
9 if let viewModel {
10 content(viewModel)
11 } else {
12 SRHTLoadingStateView(message: "Loading Activity…")
13 }
14 }
15 .navigationTitle("Activity")
16 .navigationBarTitleDisplayMode(.inline)
17 .task {
18 let model = viewModel ?? ActivityViewModel(client: appState.client)
19 viewModel = model
20 await model.loadIfNeeded()
21 }
22 }
23
24 @ViewBuilder
25 private func content(_ viewModel: ActivityViewModel) -> some View {
26 List {
27 ForEach(viewModel.events) { event in
28 NavigationLink {
29 TicketDetailView(
30 ownerUsername: event.ownerUsername,
31 trackerName: event.trackerName,
32 trackerId: event.trackerID,
33 trackerRid: event.trackerRID,
34 ticketId: event.ticketID
35 )
36 } label: {
37 ActivityRow(event: event)
38 }
39 .themedRow()
40 }
41
42 if viewModel.hasMore {
43 HStack {
44 Spacer()
45 ProgressView()
46 Spacer()
47 }
48 .themedRow()
49 .task { await viewModel.loadMore() }
50 }
51 }
52 .themedList()
53 .listStyle(.plain)
54 .refreshable { await viewModel.load() }
55 .overlay {
56 if viewModel.isLoading, viewModel.events.isEmpty {
57 SRHTLoadingStateView(message: "Loading Activity…")
58 } else if let error = viewModel.error, viewModel.events.isEmpty {
59 SRHTErrorStateView(
60 title: "Couldn't Load Activity",
61 message: error,
62 retryAction: { await viewModel.load() }
63 )
64 } else if viewModel.events.isEmpty {
65 ContentUnavailableView(
66 "No Activity",
67 systemImage: "bell",
68 description: Text("Ticket activity you are subscribed to or involved in appears here.")
69 )
70 }
71 }
72 }
73}
74
75private struct ActivityRow: View {
76 let event: ActivityEvent
77
78 var body: some View {
79 VStack(alignment: .leading, spacing: 4) {
80 Text(event.ticketSubject)
81 .font(.subheadline.weight(.medium))
82 .lineLimit(2)
83
84 Text("\(event.summary) • \(event.created.relativeDescription)")
85 .font(.caption)
86 .foregroundStyle(.secondary)
87
88 Text("\(event.trackerOwner.canonicalName)/\(event.trackerName) #\(event.ticketID)")
89 .font(.caption2)
90 .foregroundStyle(.tertiary)
91 }
92 .padding(.vertical, 2)
93 .accessibilityElement(children: .combine)
94 .accessibilityLabel("\(event.ticketSubject), \(event.summary), \(event.created.relativeDescription)")
95 }
96}
Hutch/Views/Activity/ActivityViewModel.swift added +210
@@ -0,0 +1,210 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct ActivityResponse: Decodable, Sendable {
6 /// Nullable in the schema, and null when the token lacks the EVENTS scope.
7 let events: ActivityPage?
8}
9
10private struct ActivityPage: Decodable, Sendable {
11 let results: [ActivityEventPayload]
12 let cursor: String?
13}
14
15private struct ActivityEventPayload: Decodable, Sendable {
16 let id: Int
17 let created: Date
18 let changes: [EventChange]
19 let ticket: ActivityTicketPayload
20}
21
22private struct ActivityTicketPayload: Decodable, Sendable {
23 let id: Int
24 let subject: String
25 let tracker: ActivityTrackerPayload
26}
27
28private struct ActivityTrackerPayload: Decodable, Sendable {
29 let id: Int
30 let rid: String
31 let name: String
32 let owner: Entity
33}
34
35// MARK: - View Model
36
37/// The authenticated user's ticket activity across every tracker.
38///
39/// todo.sr.ht's root `events` returns what the user is subscribed to or
40/// implicated in, newest first — the closest thing sr.ht offers to a personal
41/// feed, and it works across trackers the user does not own.
42@Observable
43@MainActor
44final class ActivityViewModel {
45
46 private(set) var events: [ActivityEvent] = []
47 private(set) var isLoading = false
48 private(set) var isLoadingMore = false
49 private(set) var hasMore = false
50 var error: String?
51
52 private var cursor: String?
53 private let client: SRHTClient
54
55 init(client: SRHTClient) {
56 self.client = client
57 }
58
59 private static let eventsQuery = """
60 query activity($cursor: Cursor) {
61 events(cursor: $cursor) {
62 results {
63 id
64 created
65 changes {
66 eventType: __typename
67 ... on Created { __typename }
68 ... on Comment {
69 author { canonicalName }
70 text
71 authenticity
72 }
73 ... on StatusChange {
74 oldStatus
75 newStatus
76 }
77 ... on LabelUpdate {
78 labeler { canonicalName }
79 label { name }
80 }
81 ... on Assignment {
82 assigner { canonicalName }
83 assignee { canonicalName }
84 }
85 }
86 ticket {
87 id
88 subject
89 tracker { id rid name owner { canonicalName } }
90 }
91 }
92 cursor
93 }
94 }
95 """
96
97 func loadIfNeeded() async {
98 guard events.isEmpty, !isLoading else { return }
99 await load()
100 }
101
102 func load() async {
103 guard !isLoading else { return }
104 isLoading = true
105 error = nil
106 defer { isLoading = false }
107
108 cursor = nil
109 do {
110 let page = try await fetch(cursor: nil)
111 events = page.events
112 cursor = page.cursor
113 hasMore = page.cursor != nil
114 } catch {
115 self.error = error.userFacingMessage
116 }
117 }
118
119 func loadMore() async {
120 guard !isLoadingMore, !isLoading, let cursor else { return }
121 isLoadingMore = true
122 defer { isLoadingMore = false }
123
124 do {
125 let page = try await fetch(cursor: cursor)
126 events.append(contentsOf: page.events)
127 self.cursor = page.cursor
128 hasMore = page.cursor != nil
129 } catch {
130 // Keep what is already on screen; the next scroll can retry.
131 self.error = error.userFacingMessage
132 }
133 }
134
135 private func fetch(cursor: String?) async throws -> (events: [ActivityEvent], cursor: String?) {
136 var variables: [String: any Sendable] = [:]
137 if let cursor {
138 variables["cursor"] = cursor
139 }
140
141 let response = try await client.execute(
142 service: .todo,
143 query: Self.eventsQuery,
144 variables: variables.isEmpty ? nil : variables,
145 responseType: ActivityResponse.self
146 )
147
148 guard let page = response.events else {
149 throw SRHTError.graphQLErrors([
150 GraphQLError(message: "Your token does not grant access to ticket events.", locations: nil)
151 ])
152 }
153
154 let mapped = page.results.map { payload in
155 ActivityEvent(
156 id: payload.id,
157 created: payload.created,
158 changes: payload.changes,
159 ticketID: payload.ticket.id,
160 ticketSubject: payload.ticket.subject,
161 trackerID: payload.ticket.tracker.id,
162 trackerRID: payload.ticket.tracker.rid,
163 trackerName: payload.ticket.tracker.name,
164 trackerOwner: payload.ticket.tracker.owner
165 )
166 }
167 return (mapped, page.cursor)
168 }
169}
170
171/// One entry in the activity feed, flattened so the row does not have to walk
172/// into the ticket and tracker payloads.
173struct ActivityEvent: Identifiable, Sendable {
174 let id: Int
175 let created: Date
176 let changes: [EventChange]
177 let ticketID: Int
178 let ticketSubject: String
179 let trackerID: Int
180 let trackerRID: String
181 let trackerName: String
182 let trackerOwner: Entity
183
184 var ownerUsername: String {
185 trackerOwner.canonicalName.hasPrefix("~")
186 ? String(trackerOwner.canonicalName.dropFirst())
187 : trackerOwner.canonicalName
188 }
189
190 /// A one-line description of what happened, from the first change.
191 var summary: String {
192 guard let change = changes.first else { return "Updated" }
193 switch change.eventType {
194 case "Created": return "Filed"
195 case "Comment": return "Commented"
196 case "StatusChange":
197 if let newStatus = change.newStatus {
198 return "Status \(newStatus.displayName.lowercased())"
199 }
200 return "Status changed"
201 case "LabelUpdate": return change.label.map { "Labeled \($0.name)" } ?? "Labels changed"
202 case "Assignment":
203 if let assignee = change.assignee {
204 return "Assigned \(assignee.canonicalName)"
205 }
206 return "Assignment changed"
207 default: return "Updated"
208 }
209 }
210}
Hutch/Views/Lookup/LookupView.swift +2
@@ -465,6 +465,8 @@ struct LookupView: View {
465465 ProjectDashboardDeepLinkView(projectID: id, title: title)
466466 case .mailingList(let mailingList):
467467 MailingListDetailView(mailingList: mailingList)
468 case .activity:
469 ActivityView()
468470 case .thread(let thread):
469471 ThreadDetailView(
470472 thread: thread,
Hutch/Views/More/MoreView.swift +7
@@ -19,6 +19,13 @@ struct MoreView: View {
1919 .themedRow()
2020 }
2121
22 Section("Activity") {
23 NavigationLink(value: MoreRoute.activity) {
24 Label("Ticket Activity", systemImage: "bell.badge")
25 }
26 .themedRow()
27 }
28
2229 Section("Other Services") {
2330 NavigationLink(value: MoreRoute.projects) {
2431 Label("Projects", systemImage: "square.stack.3d.up")