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/Views/Activity/ActivityViewModel.swift | |
| 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/Views/Activity/ActivityViewModel.swift')
| -rw-r--r-- | Hutch/Views/Activity/ActivityViewModel.swift | 210 |
1 files changed, 210 insertions, 0 deletions
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" + } + } +} |
