Commit d37246d764
Unsigned
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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 | |||
| 75 | private 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | ||
| 4 | |||
| 5 | private struct ActivityResponse: Decodable, Sendable { | ||
| 6 | /// Nullable in the schema, and null when the token lacks the EVENTS scope. | ||
| 7 | let events: ActivityPage? | ||
| 8 | } | ||
| 9 | |||
| 10 | private struct ActivityPage: Decodable, Sendable { | ||
| 11 | let results: [ActivityEventPayload] | ||
| 12 | let cursor: String? | ||
| 13 | } | ||
| 14 | |||
| 15 | private struct ActivityEventPayload: Decodable, Sendable { | ||
| 16 | let id: Int | ||
| 17 | let created: Date | ||
| 18 | let changes: [EventChange] | ||
| 19 | let ticket: ActivityTicketPayload | ||
| 20 | } | ||
| 21 | |||
| 22 | private struct ActivityTicketPayload: Decodable, Sendable { | ||
| 23 | let id: Int | ||
| 24 | let subject: String | ||
| 25 | let tracker: ActivityTrackerPayload | ||
| 26 | } | ||
| 27 | |||
| 28 | private 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 | ||
| 44 | final 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. | ||
| 173 | struct 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 | ||
| 145 | Four of the six planned. The other two did not survive contact: | 144 | Three 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) |