aboutsummaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-16 00:00:52 -0500
committerChristian Cleberg <[email protected]>2026-07-16 00:00:52 -0500
commit8ee93a6a6a0e771b687d1f48a59fb896ae0456e5 (patch)
treee766ac690f6b7c24eee2de3729f3c17e7e98d85a /Hutch
parent59622556deb4de7843faa493c8d5a6ef66591d91 (diff)
downloadhutch-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.swift3
-rw-r--r--Hutch/Views/Activity/ActivityView.swift96
-rw-r--r--Hutch/Views/Activity/ActivityViewModel.swift210
-rw-r--r--Hutch/Views/Lookup/LookupView.swift2
-rw-r--r--Hutch/Views/More/MoreView.swift7
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")