krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.9.0: codex-user-resource-browsing.md · raw
1# feat: User resource browsing from profile
2
3## Goal
4
5Extend `UserProfileView` so that after looking up a user, their public
6repositories and trackers are shown as browsable sections below the existing
7profile metadata. This mirrors the sr.ht `~username` page.
8
9---
10
11## Context
12
13**Entry point:** `Hutch/Views/Lookup/LookupView.swift`
14Looking up a user opens `UserProfileView` in a sheet. Currently the view only
15shows static metadata fields from the `User` model.
16
17**Owner identifier:** `user.canonicalName` (e.g. `~username`). Strip the leading
18`~` when passing to GraphQL `username` parameters — see the existing pattern in
19`AppState.resolveRepository(owner:name:)`.
20
21**Existing row views to reuse:**
22- `RepositoryRowView` in `Hutch/Views/Repositories/RepositoryRowView.swift`
23- Tracker row style from `TrackerListView` private `TrackerRowView`
24
25**API pattern for user-scoped queries** (from `AppState.swift`):
26```graphql
27query repoLookup($owner: String!, $name: String!) {
28 user(username: $owner) {
29 repository(name: $name) { ... }
30 }
31}
32```
33The same `user(username:)` root field supports `repositories` and `trackers`
34paginated collections on git.sr.ht and todo.sr.ht respectively.
35
36---
37
38## New File: `Hutch/Views/Lookup/UserProfileViewModel.swift`
39
40Create an `@Observable @MainActor` view model following the same pattern as
41`RepositoryListViewModel` and `TrackerListViewModel`.
42
43```swift
44@Observable
45@MainActor
46final class UserProfileViewModel {
47 private(set) var repositories: [RepositorySummary] = []
48 private(set) var trackers: [TrackerSummary] = []
49 private(set) var isLoadingRepositories = false
50 private(set) var isLoadingTrackers = false
51 var repositoriesError: String?
52 var trackersError: String?
53
54 private let client: SRHTClient
55 let ownerUsername: String // without leading ~
56
57 init(ownerUsername: String, client: SRHTClient) { ... }
58
59 func loadRepositories() async { ... }
60 func loadTrackers() async { ... }
61}
62```
63
64**GraphQL queries:**
65
66Repositories (execute against `.git` service):
67```graphql
68query userRepositories($owner: String!) {
69 user(username: $owner) {
70 repositories {
71 results {
72 id rid name description visibility updated
73 owner { canonicalName }
74 HEAD { name target }
75 }
76 cursor
77 }
78 }
79}
80```
81
82Trackers (execute against `.todo` service):
83```graphql
84query userTrackers($owner: String!) {
85 user(username: $owner) {
86 trackers {
87 results {
88 id rid name description visibility updated
89 owner { canonicalName }
90 }
91 cursor
92 }
93 }
94}
95```
96
97Decode using private response structs identical to those in
98`RepositoryListViewModel` and `TrackerListViewModel`. Map results to
99`RepositorySummary` and `TrackerSummary` exactly as those view models do.
100
101---
102
103## Modified File: `Hutch/Views/Lookup/UserProfileView.swift`
104
105### View model instantiation
106
107Add `@State private var profileViewModel: UserProfileViewModel?` and initialise
108it in `.task` using `user.canonicalName` with the leading `~` stripped:
109
110```swift
111.task {
112 let owner = user.canonicalName.hasPrefix("~")
113 ? String(user.canonicalName.dropFirst())
114 : user.canonicalName
115 let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client)
116 profileViewModel = vm
117 async let repos: () = vm.loadRepositories()
118 async let trackers: () = vm.loadTrackers()
119 _ = await (repos, trackers)
120}
121```
122
123Restore `@Environment(AppState.self) private var appState` (it was removed in a
124recent commit but is needed for `client` access).
125
126### Repositories section
127
128Add after the existing metadata sections:
129
130```swift
131Section {
132 if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
133 ProgressView()
134 } else if viewModel.repositories.isEmpty {
135 Text("No public repositories.")
136 .foregroundStyle(.secondary)
137 } else {
138 ForEach(viewModel.repositories.prefix(4)) { repo in
139 NavigationLink {
140 RepositoryDetailView(repository: repo)
141 } label: {
142 RepositoryRowView(repository: repo, buildStatus: .none)
143 }
144 }
145 if viewModel.repositories.count > 4 {
146 NavigationLink("See All") {
147 UserRepositoriesView(viewModel: viewModel)
148 }
149 }
150 }
151} header: {
152 Text("Repositories")
153}
154```
155
156### Trackers section
157
158Immediately after the Repositories section:
159
160```swift
161Section {
162 if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty {
163 ProgressView()
164 } else if viewModel.trackers.isEmpty {
165 Text("No public trackers.")
166 .foregroundStyle(.secondary)
167 } else {
168 ForEach(viewModel.trackers.prefix(4)) { tracker in
169 NavigationLink {
170 TicketListView(tracker: tracker)
171 } label: {
172 TrackerRowView(tracker: tracker)
173 }
174 }
175 if viewModel.trackers.count > 4 {
176 NavigationLink("See All") {
177 UserTrackersView(viewModel: viewModel)
178 }
179 }
180 }
181} header: {
182 Text("Trackers")
183}
184```
185
186`TrackerRowView` — use the same VStack layout as the private `TrackerRowView`
187in `TrackerListView.swift`. Define it as a private struct in
188`UserProfileView.swift` rather than duplicating from `TrackerListView`.
189
190---
191
192## New File: `Hutch/Views/Lookup/UserRepositoriesView.swift`
193
194A simple full-list view for "See All" repositories:
195
196```swift
197struct UserRepositoriesView: View {
198 let viewModel: UserProfileViewModel
199
200 var body: some View {
201 List {
202 ForEach(viewModel.repositories) { repo in
203 NavigationLink {
204 RepositoryDetailView(repository: repo)
205 } label: {
206 RepositoryRowView(repository: repo, buildStatus: .none)
207 }
208 }
209 }
210 .listStyle(.plain)
211 .navigationTitle("Repositories")
212 .navigationBarTitleDisplayMode(.inline)
213 .overlay {
214 if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
215 SRHTLoadingStateView(message: "Loading repositories…")
216 }
217 }
218 .refreshable {
219 await viewModel.loadRepositories()
220 }
221 }
222}
223```
224
225## New File: `Hutch/Views/Lookup/UserTrackersView.swift`
226
227Same pattern as `UserRepositoriesView` but for trackers, navigating to
228`TicketListView(tracker:)`.
229
230---
231
232## Navigation context
233
234`UserProfileView` is always presented inside a `NavigationStack` via the sheet
235in `LookupView`. `NavigationLink` destinations push correctly within that stack.
236No changes to `LookupView` or `MoreRoute` are needed.
237
238---
239
240## Verification
241
2421. Look up a user with public repositories and trackers. Confirm both sections
243 appear below the profile metadata.
2442. Tap a repository row — confirm `RepositoryDetailView` pushes correctly.
2453. Tap a tracker row — confirm `TicketListView` pushes correctly.
2464. For users with more than 4 items, confirm "See All" pushes the full list.
2475. Look up a user with no public resources — confirm the empty-state text
248 renders in each section rather than crashing.
2496. Build with no warnings.