From 8ee93a6a6a0e771b687d1f48a59fb896ae0456e5 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 00:00:52 -0500 Subject: 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. --- Hutch/App/RootView.swift | 3 +++ 1 file changed, 3 insertions(+) (limited to 'Hutch/App') diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 1ae4651..32d1624 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -439,6 +439,7 @@ enum MoreRoute: Hashable { case projectDashboard(id: String, title: String?) case mailingList(InboxMailingListReference) case thread(InboxThreadSummary) + case activity case manPageBrowser case manPage(URL) } @@ -472,6 +473,8 @@ private struct MoreNavigationRoot: View { ProjectDashboardDeepLinkView(projectID: id, title: title) case .mailingList(let mailingList): MailingListDetailView(mailingList: mailingList) + case .activity: + ActivityView() case .thread(let thread): ThreadDetailView( thread: thread, -- cgit v1.2.3 From 6898bc3fc00decee7224895ea75908daf0f97f59 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 00:29:34 -0500 Subject: fix: blank mailing list from Projects, swipe flicker, hidden upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three problems from manual testing. Opening a mailing list from More → Projects showed a blank screen, while the same tap on a project pinned to Home worked. handleTabNavigation reset the target path and appended to it two Task.yields later. When the target tab is already on screen — Projects lives under More — the reset starts an animated pop of the view the user is standing on and the appends land mid-animation. From Home the tab actually changes, so the More stack is quiescent and the appends land cleanly. Each case now builds its path and assigns it once, so SwiftUI gets a single diff with nothing to race. Destructive swipe actions made the row vanish and spring back while the confirmation was still up. role: .destructive makes SwiftUI perform the row removal on activation, which allowsFullSwipe: false does not prevent — the report was a tap, not a full swipe. These buttons only record pending state and wait for an answer, so they are plain buttons tinted red instead. Six sites: the two added here, plus trackers, pastes, and tracker ACLs and labels, which had the same flicker already. The artifacts upload control was invisible. It was declared as a toolbar item from a view that is a segment inside RepositoryDetailView's tab switch rather than its own navigation destination, so it never reached the navigation bar. It is a row in the list now, and also an action on the empty state — the overlay covers the list, and a repository with no artifacts is precisely the one that needs uploading. --- Hutch/App/RootView.swift | 48 +++++++++++++---------- Hutch/Views/Lists/MailingListListView.swift | 3 +- Hutch/Views/Pastes/PasteListView.swift | 3 +- Hutch/Views/Repositories/ArtifactsView.swift | 52 +++++++++++++++---------- Hutch/Views/Tickets/TrackerListView.swift | 3 +- Hutch/Views/Tickets/TrackerManagementView.swift | 6 ++- 6 files changed, 69 insertions(+), 46 deletions(-) (limited to 'Hutch/App') diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 32d1624..0194d63 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -291,39 +291,45 @@ struct RootView: View { } } + /// Replaces the target tab's path in one assignment. + /// + /// Resetting the path and appending to it afterwards races when the target tab + /// is already the one on screen: the reset starts an animated pop of the view + /// the user is standing on, and the appends land mid-animation, leaving a blank + /// screen. That is why opening a mailing list from a pinned project on Home + /// worked while the same tap under More → Projects did not — one changes tabs + /// and the other does not. + /// + /// Building the whole path first and assigning once gives SwiftUI a single + /// diff, with nothing to race. private func handleTabNavigation(_ target: AppState.TabNavigationTarget) { switch target { case .repository(let repository): - repoPath = NavigationPath() + var path = NavigationPath() + path.append(repository) + repoPath = path appState.selectedTab = .repositories - Task { - await settleNavigationTransition() - repoPath.append(repository) - } case .tracker(let tracker): - ticketsPath = NavigationPath() + var path = NavigationPath() + path.append(tracker) + ticketsPath = path appState.selectedTab = .tickets - Task { - await settleNavigationTransition() - ticketsPath.append(tracker) - } case .mailingList(let mailingList): - morePath = NavigationPath() + // .lists first so back lands on Mailing Lists rather than dead-ending. + var path = NavigationPath() + path.append(MoreRoute.lists) + path.append(MoreRoute.mailingList(mailingList)) + morePath = path appState.selectedTab = .more - Task { - await settleNavigationTransition() - morePath.append(MoreRoute.lists) - morePath.append(MoreRoute.mailingList(mailingList)) - } + case .systemStatus: - morePath = NavigationPath() + var path = NavigationPath() + path.append(MoreRoute.systemStatus) + morePath = path appState.selectedTab = .more - Task { - await settleNavigationTransition() - morePath.append(MoreRoute.systemStatus) - } + case .builds: buildsPath = NavigationPath() appState.selectedTab = .builds diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift index 77df89b..1b159bf 100644 --- a/Hutch/Views/Lists/MailingListListView.swift +++ b/Hutch/Views/Lists/MailingListListView.swift @@ -373,11 +373,12 @@ struct MailingListListView: View { // the data has not actually changed. .swipeActions(edge: .trailing, allowsFullSwipe: false) { if isOwned(mailingList) { - Button(role: .destructive) { + Button { pendingDeletion = mailingList } label: { SwiftUI.Label("Delete", systemImage: "trash") } + .tint(.red) Button { editingList = mailingList } label: { diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift index b325153..b2c7838 100644 --- a/Hutch/Views/Pastes/PasteListView.swift +++ b/Hutch/Views/Pastes/PasteListView.swift @@ -90,11 +90,12 @@ struct PasteListView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if swipeActionsEnabled { - Button(role: .destructive) { + Button { pasteToDelete = paste } label: { Label("Delete", systemImage: "trash") } + .tint(.red) } } .task { diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index 037c092..752c7c5 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -16,6 +16,21 @@ struct ArtifactsView: View { var body: some View { List { + // In the list rather than the toolbar: this view is a segment inside + // RepositoryDetailView's tab switch, not its own navigation + // destination, and a toolbar declared from there does not reliably + // reach the navigation bar. It also has to be reachable when there are + // no artifacts at all, which is the state a new tag is in. + if isOwnedByCurrentUser { + Button { + showTagPicker = true + } label: { + SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") + } + .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) + .themedRow() + } + ForEach(viewModel.referenceArtifacts) { refArtifacts in Section { ForEach(refArtifacts.artifacts) { artifact in @@ -26,11 +41,12 @@ struct ArtifactsView: View { // action animates the row out before the confirmation. .swipeActions(edge: .trailing, allowsFullSwipe: false) { if isOwnedByCurrentUser { - Button(role: .destructive) { + Button { pendingDeletion = artifact } label: { SwiftUI.Label("Delete", systemImage: "trash") } + .tint(.red) } } } @@ -83,20 +99,6 @@ struct ArtifactsView: View { } message: { _ in Text("This permanently removes the artifact from the tag. This cannot be undone.") } - // The sections above only list tags that already have an artifact, so - // without this there would be no way to attach the first one to a tag. - .toolbar { - if isOwnedByCurrentUser { - ToolbarItem(placement: .topBarTrailing) { - Button { - showTagPicker = true - } label: { - SwiftUI.Label("Upload Artifact", systemImage: "square.and.arrow.up") - } - .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) - } - } - } .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) { ForEach(viewModel.tags.prefix(12), id: \.name) { tag in Button(RepositorySummary.displayBranchName(for: tag.name)) { @@ -125,11 +127,21 @@ struct ArtifactsView: View { retryAction: { await viewModel.loadArtifacts() } ) } else if viewModel.referenceArtifacts.isEmpty { - ContentUnavailableView( - "No Artifacts", - systemImage: "archivebox", - description: Text("This repository has no release artifacts.") - ) + // The overlay covers the whole list, so the upload row above is + // hidden underneath it — and a repository with no artifacts is + // exactly the one that needs uploading. Offer it here too. + ContentUnavailableView { + SwiftUI.Label("No Artifacts", systemImage: "archivebox") + } description: { + Text("This repository has no release artifacts.") + } actions: { + if isOwnedByCurrentUser { + Button("Upload Artifact…") { + showTagPicker = true + } + .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) + } + } } } .task { diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift index 5deb513..cb4a59c 100644 --- a/Hutch/Views/Tickets/TrackerListView.swift +++ b/Hutch/Views/Tickets/TrackerListView.swift @@ -145,11 +145,12 @@ struct TrackerListView: View { TrackerRowView(tracker: tracker) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { + Button { pendingDeletion = tracker } label: { Label("Delete", systemImage: "trash") } + .tint(.red) Button { editingTracker = tracker diff --git a/Hutch/Views/Tickets/TrackerManagementView.swift b/Hutch/Views/Tickets/TrackerManagementView.swift index fad1ae8..73b2a08 100644 --- a/Hutch/Views/Tickets/TrackerManagementView.swift +++ b/Hutch/Views/Tickets/TrackerManagementView.swift @@ -751,11 +751,12 @@ struct TrackerACLManagementSheet: View { TrackerPermissionSummary(permissions: entry.permissions) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { + Button { pendingDeletion = entry } label: { Label("Delete", systemImage: "trash") } + .tint(.red) Button { editingACL = entry @@ -1132,11 +1133,12 @@ struct TrackerLabelManagementSheet: View { } .tint(.blue) - Button(role: .destructive) { + Button { pendingDeletion = label } label: { Label("Delete", systemImage: "trash") } + .tint(.red) } } .themedRow() -- cgit v1.2.3 From 77cd5b5e56ec054b451a6165162fef655873a5d6 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 00:42:38 -0500 Subject: fix: push mailing lists locally, fix upload menu, drop the events feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Hutch/App/RootView.swift | 3 - Hutch/Views/Activity/ActivityView.swift | 96 ------------ Hutch/Views/Activity/ActivityViewModel.swift | 210 --------------------------- Hutch/Views/Lookup/LookupView.swift | 2 - Hutch/Views/More/MoreView.swift | 7 - Hutch/Views/Projects/ProjectDetailView.swift | 11 +- Hutch/Views/Repositories/ArtifactsView.swift | 46 +++--- README.md | 1 - ROADMAP.md | 7 +- SCOPE.md | 15 ++ 10 files changed, 52 insertions(+), 346 deletions(-) delete mode 100644 Hutch/Views/Activity/ActivityView.swift delete mode 100644 Hutch/Views/Activity/ActivityViewModel.swift (limited to 'Hutch/App') diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 0194d63..10720ad 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -445,7 +445,6 @@ enum MoreRoute: Hashable { case projectDashboard(id: String, title: String?) case mailingList(InboxMailingListReference) case thread(InboxThreadSummary) - case activity case manPageBrowser case manPage(URL) } @@ -479,8 +478,6 @@ private struct MoreNavigationRoot: View { ProjectDashboardDeepLinkView(projectID: id, title: title) case .mailingList(let mailingList): MailingListDetailView(mailingList: mailingList) - case .activity: - ActivityView() case .thread(let thread): ThreadDetailView( thread: thread, diff --git a/Hutch/Views/Activity/ActivityView.swift b/Hutch/Views/Activity/ActivityView.swift deleted file mode 100644 index 6379d45..0000000 --- a/Hutch/Views/Activity/ActivityView.swift +++ /dev/null @@ -1,96 +0,0 @@ -import SwiftUI - -struct ActivityView: View { - @Environment(AppState.self) private var appState - @State private var viewModel: ActivityViewModel? - - var body: some View { - Group { - if let viewModel { - content(viewModel) - } else { - SRHTLoadingStateView(message: "Loading Activity…") - } - } - .navigationTitle("Activity") - .navigationBarTitleDisplayMode(.inline) - .task { - let model = viewModel ?? ActivityViewModel(client: appState.client) - viewModel = model - await model.loadIfNeeded() - } - } - - @ViewBuilder - private func content(_ viewModel: ActivityViewModel) -> some View { - List { - ForEach(viewModel.events) { event in - NavigationLink { - TicketDetailView( - ownerUsername: event.ownerUsername, - trackerName: event.trackerName, - trackerId: event.trackerID, - trackerRid: event.trackerRID, - ticketId: event.ticketID - ) - } label: { - ActivityRow(event: event) - } - .themedRow() - } - - if viewModel.hasMore { - HStack { - Spacer() - ProgressView() - Spacer() - } - .themedRow() - .task { await viewModel.loadMore() } - } - } - .themedList() - .listStyle(.plain) - .refreshable { await viewModel.load() } - .overlay { - if viewModel.isLoading, viewModel.events.isEmpty { - SRHTLoadingStateView(message: "Loading Activity…") - } else if let error = viewModel.error, viewModel.events.isEmpty { - SRHTErrorStateView( - title: "Couldn't Load Activity", - message: error, - retryAction: { await viewModel.load() } - ) - } else if viewModel.events.isEmpty { - ContentUnavailableView( - "No Activity", - systemImage: "bell", - description: Text("Ticket activity you are subscribed to or involved in appears here.") - ) - } - } - } -} - -private struct ActivityRow: View { - let event: ActivityEvent - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(event.ticketSubject) - .font(.subheadline.weight(.medium)) - .lineLimit(2) - - Text("\(event.summary) • \(event.created.relativeDescription)") - .font(.caption) - .foregroundStyle(.secondary) - - Text("\(event.trackerOwner.canonicalName)/\(event.trackerName) #\(event.ticketID)") - .font(.caption2) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 2) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(event.ticketSubject), \(event.summary), \(event.created.relativeDescription)") - } -} diff --git a/Hutch/Views/Activity/ActivityViewModel.swift b/Hutch/Views/Activity/ActivityViewModel.swift deleted file mode 100644 index 77e500d..0000000 --- a/Hutch/Views/Activity/ActivityViewModel.swift +++ /dev/null @@ -1,210 +0,0 @@ -import Foundation - -// MARK: - Response types (file-private to avoid @MainActor Decodable issues) - -private struct ActivityResponse: Decodable, Sendable { - /// Nullable in the schema, and null when the token lacks the EVENTS scope. - let events: ActivityPage? -} - -private struct ActivityPage: Decodable, Sendable { - let results: [ActivityEventPayload] - let cursor: String? -} - -private struct ActivityEventPayload: Decodable, Sendable { - let id: Int - let created: Date - let changes: [EventChange] - let ticket: ActivityTicketPayload -} - -private struct ActivityTicketPayload: Decodable, Sendable { - let id: Int - let subject: String - let tracker: ActivityTrackerPayload -} - -private struct ActivityTrackerPayload: Decodable, Sendable { - let id: Int - let rid: String - let name: String - let owner: Entity -} - -// MARK: - View Model - -/// The authenticated user's ticket activity across every tracker. -/// -/// todo.sr.ht's root `events` returns what the user is subscribed to or -/// implicated in, newest first — the closest thing sr.ht offers to a personal -/// feed, and it works across trackers the user does not own. -@Observable -@MainActor -final class ActivityViewModel { - - private(set) var events: [ActivityEvent] = [] - private(set) var isLoading = false - private(set) var isLoadingMore = false - private(set) var hasMore = false - var error: String? - - private var cursor: String? - private let client: SRHTClient - - init(client: SRHTClient) { - self.client = client - } - - private static let eventsQuery = """ - query activity($cursor: Cursor) { - events(cursor: $cursor) { - results { - id - created - changes { - eventType: __typename - ... on Created { __typename } - ... on Comment { - author { canonicalName } - text - authenticity - } - ... on StatusChange { - oldStatus - newStatus - } - ... on LabelUpdate { - labeler { canonicalName } - label { name } - } - ... on Assignment { - assigner { canonicalName } - assignee { canonicalName } - } - } - ticket { - id - subject - tracker { id rid name owner { canonicalName } } - } - } - cursor - } - } - """ - - func loadIfNeeded() async { - guard events.isEmpty, !isLoading else { return } - await load() - } - - func load() async { - guard !isLoading else { return } - isLoading = true - error = nil - defer { isLoading = false } - - cursor = nil - do { - let page = try await fetch(cursor: nil) - events = page.events - cursor = page.cursor - hasMore = page.cursor != nil - } catch { - self.error = error.userFacingMessage - } - } - - func loadMore() async { - guard !isLoadingMore, !isLoading, let cursor else { return } - isLoadingMore = true - defer { isLoadingMore = false } - - do { - let page = try await fetch(cursor: cursor) - events.append(contentsOf: page.events) - self.cursor = page.cursor - hasMore = page.cursor != nil - } catch { - // Keep what is already on screen; the next scroll can retry. - self.error = error.userFacingMessage - } - } - - private func fetch(cursor: String?) async throws -> (events: [ActivityEvent], cursor: String?) { - var variables: [String: any Sendable] = [:] - if let cursor { - variables["cursor"] = cursor - } - - let response = try await client.execute( - service: .todo, - query: Self.eventsQuery, - variables: variables.isEmpty ? nil : variables, - responseType: ActivityResponse.self - ) - - guard let page = response.events else { - throw SRHTError.graphQLErrors([ - GraphQLError(message: "Your token does not grant access to ticket events.", locations: nil) - ]) - } - - let mapped = page.results.map { payload in - ActivityEvent( - id: payload.id, - created: payload.created, - changes: payload.changes, - ticketID: payload.ticket.id, - ticketSubject: payload.ticket.subject, - trackerID: payload.ticket.tracker.id, - trackerRID: payload.ticket.tracker.rid, - trackerName: payload.ticket.tracker.name, - trackerOwner: payload.ticket.tracker.owner - ) - } - return (mapped, page.cursor) - } -} - -/// One entry in the activity feed, flattened so the row does not have to walk -/// into the ticket and tracker payloads. -struct ActivityEvent: Identifiable, Sendable { - let id: Int - let created: Date - let changes: [EventChange] - let ticketID: Int - let ticketSubject: String - let trackerID: Int - let trackerRID: String - let trackerName: String - let trackerOwner: Entity - - var ownerUsername: String { - trackerOwner.canonicalName.hasPrefix("~") - ? String(trackerOwner.canonicalName.dropFirst()) - : trackerOwner.canonicalName - } - - /// A one-line description of what happened, from the first change. - var summary: String { - guard let change = changes.first else { return "Updated" } - switch change.eventType { - case "Created": return "Filed" - case "Comment": return "Commented" - case "StatusChange": - if let newStatus = change.newStatus { - return "Status \(newStatus.displayName.lowercased())" - } - return "Status changed" - case "LabelUpdate": return change.label.map { "Labeled \($0.name)" } ?? "Labels changed" - case "Assignment": - if let assignee = change.assignee { - return "Assigned \(assignee.canonicalName)" - } - return "Assignment changed" - default: return "Updated" - } - } -} diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift index 3b35325..2a26282 100644 --- a/Hutch/Views/Lookup/LookupView.swift +++ b/Hutch/Views/Lookup/LookupView.swift @@ -465,8 +465,6 @@ struct LookupView: View { ProjectDashboardDeepLinkView(projectID: id, title: title) case .mailingList(let mailingList): MailingListDetailView(mailingList: mailingList) - case .activity: - ActivityView() case .thread(let thread): ThreadDetailView( thread: thread, diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index eda918f..ad02077 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -19,13 +19,6 @@ struct MoreView: View { .themedRow() } - Section("Activity") { - NavigationLink(value: MoreRoute.activity) { - Label("Ticket Activity", systemImage: "bell.badge") - } - .themedRow() - } - Section("Other Services") { NavigationLink(value: MoreRoute.projects) { Label("Projects", systemImage: "square.stack.3d.up") diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift index b5c6cdb..c30ec88 100644 --- a/Hutch/Views/Projects/ProjectDetailView.swift +++ b/Hutch/Views/Projects/ProjectDetailView.swift @@ -196,9 +196,14 @@ struct ProjectDetailView: View { if !displayedProject.mailingLists.isEmpty { Section("Mailing Lists") { ForEach(displayedProject.mailingLists) { mailingList in - Button { - appState.openMailingList(mailingList.inboxReference) - dismiss() + // Pushed here rather than routed through AppState. Projects + // already lives in the More tab, so asking for a tab + // navigation made the path rebuild itself while dismiss() + // popped this view out from under it, leaving a blank screen. + // Sources and trackers still route, because they genuinely + // land in other tabs. + NavigationLink { + MailingListDetailView(mailingList: mailingList.inboxReference) } label: { ProjectResourceRow( title: mailingList.displayName, diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index 752c7c5..30ab0b1 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -10,10 +10,30 @@ struct ArtifactsView: View { @State private var uploadTargetRef: String? @State private var pendingDeletion: ArtifactInfo? - @State private var showTagPicker = false private var isOwnedByCurrentUser: Bool { canManage } + /// A menu rather than a confirmation dialog: this view already presents one + /// for delete, and two .confirmationDialog modifiers on the same view leave + /// one of them silently dead. A menu also puts the tags one tap away. + @ViewBuilder + private var uploadMenu: some View { + Menu { + if viewModel.tags.isEmpty { + Text("This repository has no tags") + } else { + ForEach(viewModel.tags.prefix(12), id: \.name) { tag in + Button(RepositorySummary.displayBranchName(for: tag.name)) { + uploadTargetRef = tag.name + } + } + } + } label: { + SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") + } + .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) + } + var body: some View { List { // In the list rather than the toolbar: this view is a segment inside @@ -22,13 +42,8 @@ struct ArtifactsView: View { // reach the navigation bar. It also has to be reachable when there are // no artifacts at all, which is the state a new tag is in. if isOwnedByCurrentUser { - Button { - showTagPicker = true - } label: { - SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") - } - .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) - .themedRow() + uploadMenu + .themedRow() } ForEach(viewModel.referenceArtifacts) { refArtifacts in @@ -99,16 +114,6 @@ struct ArtifactsView: View { } message: { _ in Text("This permanently removes the artifact from the tag. This cannot be undone.") } - .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) { - ForEach(viewModel.tags.prefix(12), id: \.name) { tag in - Button(RepositorySummary.displayBranchName(for: tag.name)) { - uploadTargetRef = tag.name - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("Artifacts attach to a tag. Filenames must be unique within the repository.") - } .themedList() .listStyle(.insetGrouped) .task { @@ -136,10 +141,7 @@ struct ArtifactsView: View { Text("This repository has no release artifacts.") } actions: { if isOwnedByCurrentUser { - Button("Upload Artifact…") { - showTagPicker = true - } - .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) + uploadMenu } } } diff --git a/README.md b/README.md index 721d456..34c7d03 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ The app currently includes: - Inbox and mailing list reading flows - Patchset review: cover letters, per-patch diffs, checks, version chains, and status changes - Mailing list creation, settings, and deletion -- Ticket activity feed across every tracker you follow - Paste browsing, creation, and detail views - Profile and account settings, including SSH keys, PGP keys, personal access token management, and the audit log - Email preferences for todo.sr.ht and lists.sr.ht diff --git a/ROADMAP.md b/ROADMAP.md index 7c64af3..2436877 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,11 +140,14 @@ are sized very differently — measure before committing to one. - ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile. - ~~Mailing list creation and settings~~ (`createMailingList`, `updateMailingList`, `deleteMailingList`). -- ~~`events` feed (todo.sr.ht)~~ — a ticket activity feed under More. -Four of the six planned. The other two did not survive contact: +Three of the six planned. The other three did not survive contact: - `archiveMessage` is `@internal` and inaccessible. +- The `events` feed was built, then removed: todo.sr.ht's root `events` resolver + joins `event.participant_id` against `participant.user_id`, which are + different id spaces, so it returns an empty list for everyone. See + [SCOPE.md](SCOPE.md). - Webhook management, `shareSecret`, and build groups are reachable but declined on judgement — see [SCOPE.md](SCOPE.md) for the reasoning, so they do not get re-proposed. diff --git a/SCOPE.md b/SCOPE.md index 008d084..407f292 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -8,6 +8,21 @@ - Pronouns on profile (not in GraphQL schema) - Revoke personal access tokens (`@internal` in schema, inaccessible) - Archive a message to a list (`archiveMessage` is `@internal`, inaccessible) +- Ticket activity feed (todo.sr.ht's root `events` query is broken upstream and + returns an empty list for every user). `event.participant_id` references + `participant(id)`, but the resolver joins it against `participant.user_id`: + + ```sql + FROM event ev + JOIN participant p ON p.user_id = ev.participant_id -- id space vs user id space + WHERE p.user_id = + ``` + + The rows exist — the writer inserts `participant.ID` for the submitter and for + every subscriber — but that join cannot find them. `Ticket.events` is + unaffected because it filters on `ev.ticket_id`, which is why ticket timelines + work. Nothing a client can do fixes this; revisit only if sr.ht changes the + resolver. - Subscribe to a mailing list (`mailingListSubscribe` exists, but `MailingList` has no `subscription` field and sr.ht has no discovery API, so there is no way to find a list you are not already subscribed to — see hub.sr.ht above) -- cgit v1.2.3