summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-16 00:42:38 -0500
committerChristian Cleberg <[email protected]>2026-07-16 00:42:38 -0500
commit77cd5b5e56ec054b451a6165162fef655873a5d6 (patch)
tree6160d1be989c9da7d97629a764e68193265d76bd /Hutch/Views
parent6898bc3fc00decee7224895ea75908daf0f97f59 (diff)
downloadhutch-77cd5b5e56ec054b451a6165162fef655873a5d6.tar.gz
hutch-77cd5b5e56ec054b451a6165162fef655873a5d6.tar.bz2
hutch-77cd5b5e56ec054b451a6165162fef655873a5d6.zip
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.
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Activity/ActivityView.swift96
-rw-r--r--Hutch/Views/Activity/ActivityViewModel.swift210
-rw-r--r--Hutch/Views/Lookup/LookupView.swift2
-rw-r--r--Hutch/Views/More/MoreView.swift7
-rw-r--r--Hutch/Views/Projects/ProjectDetailView.swift11
-rw-r--r--Hutch/Views/Repositories/ArtifactsView.swift46
6 files changed, 32 insertions, 340 deletions
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
}
}
}