diff options
| author | Christian Cleberg <[email protected]> | 2026-07-16 00:00:52 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-16 00:00:52 -0500 |
| commit | 8ee93a6a6a0e771b687d1f48a59fb896ae0456e5 (patch) | |
| tree | e766ac690f6b7c24eee2de3729f3c17e7e98d85a /Hutch | |
| parent | 59622556deb4de7843faa493c8d5a6ef66591d91 (diff) | |
| download | hutch-8ee93a6a6a0e771b687d1f48a59fb896ae0456e5.tar.gz hutch-8ee93a6a6a0e771b687d1f48a59fb896ae0456e5.tar.bz2 hutch-8ee93a6a6a0e771b687d1f48a59fb896ae0456e5.zip | |
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.
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/RootView.swift | 3 | ||||
| -rw-r--r-- | Hutch/Views/Activity/ActivityView.swift | 96 | ||||
| -rw-r--r-- | Hutch/Views/Activity/ActivityViewModel.swift | 210 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreView.swift | 7 |
5 files changed, 318 insertions, 0 deletions
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") |
