Commit d37246d764

d37246d76418467f73102d4ac940d1023f441c8d

parent: 6b2371ec5c

Unsigned

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

fix: push mailing lists locally, fix upload menu, drop the events feed

Opening a mailing list from More → Projects still blanked. The cause was not in
handleTabNavigation: the row called openMailingList and then dismiss(), so a
path rebuild and a pop of this very view raced each other. Projects already
lives in the More tab, so there is nothing to navigate to — push
MailingListDetailView directly, which also lands back on the project rather than
on Mailing Lists. Sources and trackers keep routing, because they really do land
in other tabs.

The upload controls did nothing. Two .confirmationDialog modifiers on one view
leave one silently dead, and this view already had one for delete, so the tag
picker never presented. It is a Menu now, which also puts the tags one tap away
instead of two.

The ticket activity feed is removed. todo.sr.ht's root events resolver joins
event.participant_id, which references participant(id), against
participant.user_id — different id spaces — so it returns an empty list for
every user. The rows exist; that join cannot find them. Ticket.events is
unaffected because it filters on ticket_id, which is why ticket timelines work.
No client can fix this, and a screen that is permanently empty while blaming the
token's scopes is worse than no screen. Recorded in SCOPE.md with the query.

Layout: unified · split

Hutch/App/RootView.swift −3
@@ -445,7 +445,6 @@ enum MoreRoute: Hashable {
445 case projectDashboard(id: String, title: String?) 445 case projectDashboard(id: String, title: String?)
446 case mailingList(InboxMailingListReference) 446 case mailingList(InboxMailingListReference)
447 case thread(InboxThreadSummary) 447 case thread(InboxThreadSummary)
448 case activity
449 case manPageBrowser 448 case manPageBrowser
450 case manPage(URL) 449 case manPage(URL)
451} 450}
@@ -479,8 +478,6 @@ private struct MoreNavigationRoot: View {
479 ProjectDashboardDeepLinkView(projectID: id, title: title) 478 ProjectDashboardDeepLinkView(projectID: id, title: title)
480 case .mailingList(let mailingList): 479 case .mailingList(let mailingList):
481 MailingListDetailView(mailingList: mailingList) 480 MailingListDetailView(mailingList: mailingList)
482 case .activity:
483 ActivityView()
484 case .thread(let thread): 481 case .thread(let thread):
485 ThreadDetailView( 482 ThreadDetailView(
486 thread: thread, 483 thread: thread,
Hutch/Views/Activity/ActivityView.swift deleted −96
@@ -1,96 +0,0 @@
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 deleted −210
@@ -1,210 +0,0 @@
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,8 +465,6 @@ struct LookupView: View {
465 ProjectDashboardDeepLinkView(projectID: id, title: title) 465 ProjectDashboardDeepLinkView(projectID: id, title: title)
466 case .mailingList(let mailingList): 466 case .mailingList(let mailingList):
467 MailingListDetailView(mailingList: mailingList) 467 MailingListDetailView(mailingList: mailingList)
468 case .activity:
469 ActivityView()
470 case .thread(let thread): 468 case .thread(let thread):
471 ThreadDetailView( 469 ThreadDetailView(
472 thread: thread, 470 thread: thread,
Hutch/Views/More/MoreView.swift −7
@@ -19,13 +19,6 @@ struct MoreView: View {
19 .themedRow() 19 .themedRow()
20 } 20 }
21 21
22 Section("Activity") {
23 NavigationLink(value: MoreRoute.activity) {
24 Label("Ticket Activity", systemImage: "bell.badge")
25 }
26 .themedRow()
27 }
28
29 Section("Other Services") { 22 Section("Other Services") {
30 NavigationLink(value: MoreRoute.projects) { 23 NavigationLink(value: MoreRoute.projects) {
31 Label("Projects", systemImage: "square.stack.3d.up") 24 Label("Projects", systemImage: "square.stack.3d.up")
Hutch/Views/Projects/ProjectDetailView.swift +8 −3
@@ -196,9 +196,14 @@ struct ProjectDetailView: View {
196 if !displayedProject.mailingLists.isEmpty { 196 if !displayedProject.mailingLists.isEmpty {
197 Section("Mailing Lists") { 197 Section("Mailing Lists") {
198 ForEach(displayedProject.mailingLists) { mailingList in 198 ForEach(displayedProject.mailingLists) { mailingList in
199 Button { 199 // Pushed here rather than routed through AppState. Projects
200 appState.openMailingList(mailingList.inboxReference) 200 // already lives in the More tab, so asking for a tab
201 dismiss() 201 // navigation made the path rebuild itself while dismiss()
202 // popped this view out from under it, leaving a blank screen.
203 // Sources and trackers still route, because they genuinely
204 // land in other tabs.
205 NavigationLink {
206 MailingListDetailView(mailingList: mailingList.inboxReference)
202 } label: { 207 } label: {
203 ProjectResourceRow( 208 ProjectResourceRow(
204 title: mailingList.displayName, 209 title: mailingList.displayName,
Hutch/Views/Repositories/ArtifactsView.swift +24 −22
@@ -10,10 +10,30 @@ struct ArtifactsView: View {
10 10
11 @State private var uploadTargetRef: String? 11 @State private var uploadTargetRef: String?
12 @State private var pendingDeletion: ArtifactInfo? 12 @State private var pendingDeletion: ArtifactInfo?
13 @State private var showTagPicker = false
14 13
15 private var isOwnedByCurrentUser: Bool { canManage } 14 private var isOwnedByCurrentUser: Bool { canManage }
16 15
16 /// A menu rather than a confirmation dialog: this view already presents one
17 /// for delete, and two .confirmationDialog modifiers on the same view leave
18 /// one of them silently dead. A menu also puts the tags one tap away.
19 @ViewBuilder
20 private var uploadMenu: some View {
21 Menu {
22 if viewModel.tags.isEmpty {
23 Text("This repository has no tags")
24 } else {
25 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
26 Button(RepositorySummary.displayBranchName(for: tag.name)) {
27 uploadTargetRef = tag.name
28 }
29 }
30 }
31 } label: {
32 SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up")
33 }
34 .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty)
35 }
36
17 var body: some View { 37 var body: some View {
18 List { 38 List {
19 // In the list rather than the toolbar: this view is a segment inside 39 // In the list rather than the toolbar: this view is a segment inside
@@ -22,13 +42,8 @@ struct ArtifactsView: View {
22 // reach the navigation bar. It also has to be reachable when there are 42 // reach the navigation bar. It also has to be reachable when there are
23 // no artifacts at all, which is the state a new tag is in. 43 // no artifacts at all, which is the state a new tag is in.
24 if isOwnedByCurrentUser { 44 if isOwnedByCurrentUser {
25 Button { 45 uploadMenu
26 showTagPicker = true 46 .themedRow()
27 } label: {
28 SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up")
29 }
30 .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty)
31 .themedRow()
32 } 47 }
33 48
34 ForEach(viewModel.referenceArtifacts) { refArtifacts in 49 ForEach(viewModel.referenceArtifacts) { refArtifacts in
@@ -99,16 +114,6 @@ struct ArtifactsView: View {
99 } message: { _ in 114 } message: { _ in
100 Text("This permanently removes the artifact from the tag. This cannot be undone.") 115 Text("This permanently removes the artifact from the tag. This cannot be undone.")
101 } 116 }
102 .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) {
103 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
104 Button(RepositorySummary.displayBranchName(for: tag.name)) {
105 uploadTargetRef = tag.name
106 }
107 }
108 Button("Cancel", role: .cancel) {}
109 } message: {
110 Text("Artifacts attach to a tag. Filenames must be unique within the repository.")
111 }
112 .themedList() 117 .themedList()
113 .listStyle(.insetGrouped) 118 .listStyle(.insetGrouped)
114 .task { 119 .task {
@@ -136,10 +141,7 @@ struct ArtifactsView: View {
136 Text("This repository has no release artifacts.") 141 Text("This repository has no release artifacts.")
137 } actions: { 142 } actions: {
138 if isOwnedByCurrentUser { 143 if isOwnedByCurrentUser {
139 Button("Upload Artifact…") { 144 uploadMenu
140 showTagPicker = true
141 }
142 .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty)
143 } 145 }
144 } 146 }
145 } 147 }
README.md −1
@@ -21,7 +21,6 @@ The app currently includes:
21- Inbox and mailing list reading flows 21- Inbox and mailing list reading flows
22- Patchset review: cover letters, per-patch diffs, checks, version chains, and status changes 22- Patchset review: cover letters, per-patch diffs, checks, version chains, and status changes
23- Mailing list creation, settings, and deletion 23- Mailing list creation, settings, and deletion
24- Ticket activity feed across every tracker you follow
25- Paste browsing, creation, and detail views 24- Paste browsing, creation, and detail views
26- Profile and account settings, including SSH keys, PGP keys, personal access token management, and the audit log 25- Profile and account settings, including SSH keys, PGP keys, personal access token management, and the audit log
27- Email preferences for todo.sr.ht and lists.sr.ht 26- Email preferences for todo.sr.ht and lists.sr.ht
ROADMAP.md +5 −2
@@ -140,11 +140,14 @@ are sized very differently — measure before committing to one.
140- ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile. 140- ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile.
141- ~~Mailing list creation and settings~~ (`createMailingList`, 141- ~~Mailing list creation and settings~~ (`createMailingList`,
142 `updateMailingList`, `deleteMailingList`). 142 `updateMailingList`, `deleteMailingList`).
143- ~~`events` feed (todo.sr.ht)~~ — a ticket activity feed under More.
144 143
145Four of the six planned. The other two did not survive contact: 144Three of the six planned. The other three did not survive contact:
146 145
147- `archiveMessage` is `@internal` and inaccessible. 146- `archiveMessage` is `@internal` and inaccessible.
147- The `events` feed was built, then removed: todo.sr.ht's root `events` resolver
148 joins `event.participant_id` against `participant.user_id`, which are
149 different id spaces, so it returns an empty list for everyone. See
150 [SCOPE.md](SCOPE.md).
148- Webhook management, `shareSecret`, and build groups are reachable but declined 151- Webhook management, `shareSecret`, and build groups are reachable but declined
149 on judgement — see [SCOPE.md](SCOPE.md) for the reasoning, so they do not get 152 on judgement — see [SCOPE.md](SCOPE.md) for the reasoning, so they do not get
150 re-proposed. 153 re-proposed.
SCOPE.md +15
@@ -8,6 +8,21 @@
8- Pronouns on profile (not in GraphQL schema) 8- Pronouns on profile (not in GraphQL schema)
9- Revoke personal access tokens (`@internal` in schema, inaccessible) 9- Revoke personal access tokens (`@internal` in schema, inaccessible)
10- Archive a message to a list (`archiveMessage` is `@internal`, inaccessible) 10- Archive a message to a list (`archiveMessage` is `@internal`, inaccessible)
11- Ticket activity feed (todo.sr.ht's root `events` query is broken upstream and
12 returns an empty list for every user). `event.participant_id` references
13 `participant(id)`, but the resolver joins it against `participant.user_id`:
14
15 ```sql
16 FROM event ev
17 JOIN participant p ON p.user_id = ev.participant_id -- id space vs user id space
18 WHERE p.user_id = <viewer>
19 ```
20
21 The rows exist — the writer inserts `participant.ID` for the submitter and for
22 every subscriber — but that join cannot find them. `Ticket.events` is
23 unaffected because it filters on `ev.ticket_id`, which is why ticket timelines
24 work. Nothing a client can do fixes this; revisit only if sr.ht changes the
25 resolver.
11- Subscribe to a mailing list (`mailingListSubscribe` exists, but `MailingList` 26- Subscribe to a mailing list (`mailingListSubscribe` exists, but `MailingList`
12 has no `subscription` field and sr.ht has no discovery API, so there is no way 27 has no `subscription` field and sr.ht has no discovery API, so there is no way
13 to find a list you are not already subscribed to — see hub.sr.ht above) 28 to find a list you are not already subscribed to — see hub.sr.ht above)