summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/Views/Lookup/UserProfileView.swift98
-rw-r--r--Hutch/Views/Lookup/UserProfileViewModel.swift151
-rw-r--r--Hutch/Views/Lookup/UserRepositoriesView.swift28
-rw-r--r--Hutch/Views/Lookup/UserTrackersView.swift28
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift19
-rw-r--r--codex-user-resource-browsing.md249
7 files changed, 571 insertions, 18 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 57dcf3e..b1e277e 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -539,7 +539,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 21;
+ CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -556,7 +556,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.8.2;
+ MARKETING_VERSION = 2.9.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 21;
+ CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -593,7 +593,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.8.2;
+ MARKETING_VERSION = 2.9.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -656,7 +656,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 21;
+ CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -666,7 +666,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.8.2;
+ MARKETING_VERSION = 2.8.3;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -685,7 +685,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 21;
+ CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -695,7 +695,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.8.2;
+ MARKETING_VERSION = 2.8.3;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift
index 4c91244..dc9197c 100644
--- a/Hutch/Views/Lookup/UserProfileView.swift
+++ b/Hutch/Views/Lookup/UserProfileView.swift
@@ -1,7 +1,10 @@
import SwiftUI
struct UserProfileView: View {
+ @Environment(AppState.self) private var appState
+
let user: User
+ @State private var profileViewModel: UserProfileViewModel?
private static let iso8601Formatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
@@ -86,10 +89,70 @@ struct UserProfileView: View {
}
}
}
+
+ if let viewModel = profileViewModel {
+ Section {
+ if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
+ ProgressView()
+ } else if viewModel.repositories.isEmpty {
+ Text("No public repositories.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(viewModel.repositories.prefix(4)) { repo in
+ NavigationLink {
+ RepositoryDetailView(repository: repo)
+ } label: {
+ RepositoryRowView(repository: repo, buildStatus: .none)
+ }
+ }
+ if viewModel.repositories.count > 4 {
+ NavigationLink("See All") {
+ UserRepositoriesView(viewModel: viewModel)
+ }
+ }
+ }
+ } header: {
+ Text("Repositories")
+ }
+
+ Section {
+ if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty {
+ ProgressView()
+ } else if viewModel.trackers.isEmpty {
+ Text("No public trackers.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(viewModel.trackers.prefix(4)) { tracker in
+ NavigationLink {
+ TicketListView(tracker: tracker)
+ } label: {
+ UserProfileTrackerRowView(tracker: tracker)
+ }
+ }
+ if viewModel.trackers.count > 4 {
+ NavigationLink("See All") {
+ UserTrackersView(viewModel: viewModel)
+ }
+ }
+ }
+ } header: {
+ Text("Trackers")
+ }
+ }
}
.listStyle(.insetGrouped)
.navigationTitle(user.canonicalName)
.navigationBarTitleDisplayMode(.inline)
+ .task {
+ let owner = user.canonicalName.hasPrefix("~")
+ ? String(user.canonicalName.dropFirst())
+ : user.canonicalName
+ let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client)
+ profileViewModel = vm
+ async let repos: () = vm.loadRepositories()
+ async let trackers: () = vm.loadTrackers()
+ _ = await (repos, trackers)
+ }
}
private func formattedTimestamp(_ value: String) -> String {
@@ -100,3 +163,38 @@ struct UserProfileView: View {
return date.formatted(date: .abbreviated, time: .shortened)
}
}
+
+struct UserProfileTrackerRowView: View {
+ let tracker: TrackerSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(tracker.name)
+ .font(.subheadline.weight(.medium))
+
+ Spacer()
+
+ VisibilityBadge(visibility: tracker.visibility)
+ }
+
+ if let owner = tracker.owner.canonicalName.split(separator: "~").last {
+ Text("~\(owner)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ if let description = tracker.description, !description.isEmpty {
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ Text(tracker.updated.relativeDescription)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Lookup/UserProfileViewModel.swift b/Hutch/Views/Lookup/UserProfileViewModel.swift
new file mode 100644
index 0000000..bec79fb
--- /dev/null
+++ b/Hutch/Views/Lookup/UserProfileViewModel.swift
@@ -0,0 +1,151 @@
+import Foundation
+
+@Observable
+@MainActor
+final class UserProfileViewModel {
+ private(set) var repositories: [RepositorySummary] = []
+ private(set) var trackers: [TrackerSummary] = []
+ private(set) var isLoadingRepositories = false
+ private(set) var isLoadingTrackers = false
+ var repositoriesError: String?
+ var trackersError: String?
+
+ private let client: SRHTClient
+ let ownerUsername: String
+
+ init(ownerUsername: String, client: SRHTClient) {
+ self.ownerUsername = ownerUsername
+ self.client = client
+ }
+
+ func loadRepositories() async {
+ isLoadingRepositories = true
+ repositoriesError = nil
+ defer { isLoadingRepositories = false }
+
+ do {
+ let result = try await client.execute(
+ service: .git,
+ query: Self.repositoriesQuery,
+ variables: ["owner": ownerUsername],
+ responseType: UserRepositoriesResponse.self
+ )
+ repositories = result.user.repositories.results.map { $0.repositorySummary(service: .git) }
+ } catch {
+ repositoriesError = error.userFacingMessage
+ }
+ }
+
+ func loadTrackers() async {
+ isLoadingTrackers = true
+ trackersError = nil
+ defer { isLoadingTrackers = false }
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.trackersQuery,
+ variables: ["owner": ownerUsername],
+ responseType: UserTrackersResponse.self
+ )
+ trackers = result.user.trackers.results
+ } catch {
+ trackersError = error.userFacingMessage
+ }
+ }
+
+ private static let repositoriesQuery = """
+ query userRepositories($owner: String!) {
+ user(username: $owner) {
+ repositories {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ HEAD { name target }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let trackersQuery = """
+ query userTrackers($owner: String!) {
+ user(username: $owner) {
+ trackers {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private struct UserRepositoriesResponse: Decodable, Sendable {
+ let user: UserRepositoriesContainer
+ }
+
+ private struct UserRepositoriesContainer: Decodable, Sendable {
+ let repositories: RepositoriesPage
+ }
+
+ private struct RepositoriesPage: Decodable, Sendable {
+ let results: [RepositoryPayload]
+ let cursor: String?
+ }
+
+ private struct RepositoryPayload: Decodable, Sendable {
+ let id: Int
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+ let head: Reference?
+
+ enum CodingKeys: String, CodingKey {
+ case id, rid, name, description, visibility, updated, owner
+ case head = "HEAD"
+ }
+
+ func repositorySummary(service: SRHTService) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: rid,
+ service: service,
+ name: name,
+ description: description,
+ visibility: visibility,
+ updated: updated,
+ owner: owner,
+ head: head
+ )
+ }
+ }
+
+ private struct UserTrackersResponse: Decodable, Sendable {
+ let user: UserTrackersContainer
+ }
+
+ private struct UserTrackersContainer: Decodable, Sendable {
+ let trackers: TrackersPage
+ }
+
+ private struct TrackersPage: Decodable, Sendable {
+ let results: [TrackerSummary]
+ let cursor: String?
+ }
+}
diff --git a/Hutch/Views/Lookup/UserRepositoriesView.swift b/Hutch/Views/Lookup/UserRepositoriesView.swift
new file mode 100644
index 0000000..52d3c15
--- /dev/null
+++ b/Hutch/Views/Lookup/UserRepositoriesView.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+
+struct UserRepositoriesView: View {
+ let viewModel: UserProfileViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.repositories) { repo in
+ NavigationLink {
+ RepositoryDetailView(repository: repo)
+ } label: {
+ RepositoryRowView(repository: repo, buildStatus: .none)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .navigationTitle("Repositories")
+ .navigationBarTitleDisplayMode(.inline)
+ .overlay {
+ if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
+ SRHTLoadingStateView(message: "Loading repositories…")
+ }
+ }
+ .refreshable {
+ await viewModel.loadRepositories()
+ }
+ }
+}
diff --git a/Hutch/Views/Lookup/UserTrackersView.swift b/Hutch/Views/Lookup/UserTrackersView.swift
new file mode 100644
index 0000000..17916da
--- /dev/null
+++ b/Hutch/Views/Lookup/UserTrackersView.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+
+struct UserTrackersView: View {
+ let viewModel: UserProfileViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.trackers) { tracker in
+ NavigationLink {
+ TicketListView(tracker: tracker)
+ } label: {
+ UserProfileTrackerRowView(tracker: tracker)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .navigationTitle("Trackers")
+ .navigationBarTitleDisplayMode(.inline)
+ .overlay {
+ if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty {
+ SRHTLoadingStateView(message: "Loading trackers…")
+ }
+ }
+ .refreshable {
+ await viewModel.loadTrackers()
+ }
+ }
+}
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index a1cea6f..4a48377 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -190,7 +190,15 @@ struct TicketListView: View {
// Tickets
ForEach(viewModel.filteredTickets) { ticket in
- NavigationLink(value: ticket) {
+ NavigationLink {
+ TicketDetailView(
+ ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+ trackerName: tracker.name,
+ trackerId: tracker.id,
+ trackerRid: tracker.rid,
+ ticketId: ticket.id
+ )
+ } label: {
TicketRowView(ticket: ticket)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
@@ -250,15 +258,6 @@ struct TicketListView: View {
.refreshable {
await viewModel.loadTickets()
}
- .navigationDestination(for: TicketSummary.self) { ticket in
- TicketDetailView(
- ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
- trackerName: tracker.name,
- trackerId: tracker.id,
- trackerRid: tracker.rid,
- ticketId: ticket.id
- )
- }
}
private var trackerActionsMenu: some View {
diff --git a/codex-user-resource-browsing.md b/codex-user-resource-browsing.md
new file mode 100644
index 0000000..c37be38
--- /dev/null
+++ b/codex-user-resource-browsing.md
@@ -0,0 +1,249 @@
+# feat: User resource browsing from profile
+
+## Goal
+
+Extend `UserProfileView` so that after looking up a user, their public
+repositories and trackers are shown as browsable sections below the existing
+profile metadata. This mirrors the sr.ht `~username` page.
+
+---
+
+## Context
+
+**Entry point:** `Hutch/Views/Lookup/LookupView.swift`
+Looking up a user opens `UserProfileView` in a sheet. Currently the view only
+shows static metadata fields from the `User` model.
+
+**Owner identifier:** `user.canonicalName` (e.g. `~username`). Strip the leading
+`~` when passing to GraphQL `username` parameters — see the existing pattern in
+`AppState.resolveRepository(owner:name:)`.
+
+**Existing row views to reuse:**
+- `RepositoryRowView` in `Hutch/Views/Repositories/RepositoryRowView.swift`
+- Tracker row style from `TrackerListView` private `TrackerRowView`
+
+**API pattern for user-scoped queries** (from `AppState.swift`):
+```graphql
+query repoLookup($owner: String!, $name: String!) {
+ user(username: $owner) {
+ repository(name: $name) { ... }
+ }
+}
+```
+The same `user(username:)` root field supports `repositories` and `trackers`
+paginated collections on git.sr.ht and todo.sr.ht respectively.
+
+---
+
+## New File: `Hutch/Views/Lookup/UserProfileViewModel.swift`
+
+Create an `@Observable @MainActor` view model following the same pattern as
+`RepositoryListViewModel` and `TrackerListViewModel`.
+
+```swift
+@Observable
+@MainActor
+final class UserProfileViewModel {
+ private(set) var repositories: [RepositorySummary] = []
+ private(set) var trackers: [TrackerSummary] = []
+ private(set) var isLoadingRepositories = false
+ private(set) var isLoadingTrackers = false
+ var repositoriesError: String?
+ var trackersError: String?
+
+ private let client: SRHTClient
+ let ownerUsername: String // without leading ~
+
+ init(ownerUsername: String, client: SRHTClient) { ... }
+
+ func loadRepositories() async { ... }
+ func loadTrackers() async { ... }
+}
+```
+
+**GraphQL queries:**
+
+Repositories (execute against `.git` service):
+```graphql
+query userRepositories($owner: String!) {
+ user(username: $owner) {
+ repositories {
+ results {
+ id rid name description visibility updated
+ owner { canonicalName }
+ HEAD { name target }
+ }
+ cursor
+ }
+ }
+}
+```
+
+Trackers (execute against `.todo` service):
+```graphql
+query userTrackers($owner: String!) {
+ user(username: $owner) {
+ trackers {
+ results {
+ id rid name description visibility updated
+ owner { canonicalName }
+ }
+ cursor
+ }
+ }
+}
+```
+
+Decode using private response structs identical to those in
+`RepositoryListViewModel` and `TrackerListViewModel`. Map results to
+`RepositorySummary` and `TrackerSummary` exactly as those view models do.
+
+---
+
+## Modified File: `Hutch/Views/Lookup/UserProfileView.swift`
+
+### View model instantiation
+
+Add `@State private var profileViewModel: UserProfileViewModel?` and initialise
+it in `.task` using `user.canonicalName` with the leading `~` stripped:
+
+```swift
+.task {
+ let owner = user.canonicalName.hasPrefix("~")
+ ? String(user.canonicalName.dropFirst())
+ : user.canonicalName
+ let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client)
+ profileViewModel = vm
+ async let repos: () = vm.loadRepositories()
+ async let trackers: () = vm.loadTrackers()
+ _ = await (repos, trackers)
+}
+```
+
+Restore `@Environment(AppState.self) private var appState` (it was removed in a
+recent commit but is needed for `client` access).
+
+### Repositories section
+
+Add after the existing metadata sections:
+
+```swift
+Section {
+ if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
+ ProgressView()
+ } else if viewModel.repositories.isEmpty {
+ Text("No public repositories.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(viewModel.repositories.prefix(4)) { repo in
+ NavigationLink {
+ RepositoryDetailView(repository: repo)
+ } label: {
+ RepositoryRowView(repository: repo, buildStatus: .none)
+ }
+ }
+ if viewModel.repositories.count > 4 {
+ NavigationLink("See All") {
+ UserRepositoriesView(viewModel: viewModel)
+ }
+ }
+ }
+} header: {
+ Text("Repositories")
+}
+```
+
+### Trackers section
+
+Immediately after the Repositories section:
+
+```swift
+Section {
+ if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty {
+ ProgressView()
+ } else if viewModel.trackers.isEmpty {
+ Text("No public trackers.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(viewModel.trackers.prefix(4)) { tracker in
+ NavigationLink {
+ TicketListView(tracker: tracker)
+ } label: {
+ TrackerRowView(tracker: tracker)
+ }
+ }
+ if viewModel.trackers.count > 4 {
+ NavigationLink("See All") {
+ UserTrackersView(viewModel: viewModel)
+ }
+ }
+ }
+} header: {
+ Text("Trackers")
+}
+```
+
+`TrackerRowView` — use the same VStack layout as the private `TrackerRowView`
+in `TrackerListView.swift`. Define it as a private struct in
+`UserProfileView.swift` rather than duplicating from `TrackerListView`.
+
+---
+
+## New File: `Hutch/Views/Lookup/UserRepositoriesView.swift`
+
+A simple full-list view for "See All" repositories:
+
+```swift
+struct UserRepositoriesView: View {
+ let viewModel: UserProfileViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.repositories) { repo in
+ NavigationLink {
+ RepositoryDetailView(repository: repo)
+ } label: {
+ RepositoryRowView(repository: repo, buildStatus: .none)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .navigationTitle("Repositories")
+ .navigationBarTitleDisplayMode(.inline)
+ .overlay {
+ if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
+ SRHTLoadingStateView(message: "Loading repositories…")
+ }
+ }
+ .refreshable {
+ await viewModel.loadRepositories()
+ }
+ }
+}
+```
+
+## New File: `Hutch/Views/Lookup/UserTrackersView.swift`
+
+Same pattern as `UserRepositoriesView` but for trackers, navigating to
+`TicketListView(tracker:)`.
+
+---
+
+## Navigation context
+
+`UserProfileView` is always presented inside a `NavigationStack` via the sheet
+in `LookupView`. `NavigationLink` destinations push correctly within that stack.
+No changes to `LookupView` or `MoreRoute` are needed.
+
+---
+
+## Verification
+
+1. Look up a user with public repositories and trackers. Confirm both sections
+ appear below the profile metadata.
+2. Tap a repository row — confirm `RepositoryDetailView` pushes correctly.
+3. Tap a tracker row — confirm `TicketListView` pushes correctly.
+4. For users with more than 4 items, confirm "See All" pushes the full list.
+5. Look up a user with no public resources — confirm the empty-state text
+ renders in each section rather than crashing.
+6. Build with no warnings.