diff options
Diffstat (limited to 'Hutch/Views/Activity')
| -rw-r--r-- | Hutch/Views/Activity/ActivityView.swift | 96 | ||||
| -rw-r--r-- | Hutch/Views/Activity/ActivityViewModel.swift | 210 |
2 files changed, 306 insertions, 0 deletions
diff --git a/Hutch/Views/Activity/ActivityView.swift b/Hutch/Views/Activity/ActivityView.swift new file mode 100644 index 0000000..6379d45 --- /dev/null +++ b/Hutch/Views/Activity/ActivityView.swift @@ -0,0 +1,96 @@ +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 new file mode 100644 index 0000000..77e500d --- /dev/null +++ b/Hutch/Views/Activity/ActivityViewModel.swift @@ -0,0 +1,210 @@ +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" + } + } +} |
