summaryrefslogtreecommitdiff
path: root/Hutch/Views/Activity
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Activity')
-rw-r--r--Hutch/Views/Activity/ActivityView.swift96
-rw-r--r--Hutch/Views/Activity/ActivityViewModel.swift210
2 files changed, 0 insertions, 306 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"
- }
- }
-}