summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/RootView.swift3
-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
7 files changed, 32 insertions, 343 deletions
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
}
}
}