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 + Hutch/Views/Activity/ActivityView.swift | 96 ++++++++++++ Hutch/Views/Activity/ActivityViewModel.swift | 210 +++++++++++++++++++++++++++ Hutch/Views/Lookup/LookupView.swift | 2 + Hutch/Views/More/MoreView.swift | 7 + 5 files changed, 318 insertions(+) create mode 100644 Hutch/Views/Activity/ActivityView.swift create mode 100644 Hutch/Views/Activity/ActivityViewModel.swift (limited to 'Hutch') 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, 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" + } + } +} diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift index 2a26282..3b35325 100644 --- a/Hutch/Views/Lookup/LookupView.swift +++ b/Hutch/Views/Lookup/LookupView.swift @@ -465,6 +465,8 @@ 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 ad02077..eda918f 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -19,6 +19,13 @@ 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") -- cgit v1.2.3