krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: Hutch/Views/Lookup/UserProfileView.swift · raw
1import SwiftUI
2
3struct UserProfileView: View {
4 @Environment(AppState.self) private var appState
5 @Environment(\.isAMOLEDTheme) private var isAMOLED
6 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
7
8 let user: User
9 @State private var profileViewModel: UserProfileViewModel?
10 @State private var pinChangeCount = 0
11
12 private static let iso8601Formatter: ISO8601DateFormatter = {
13 let formatter = ISO8601DateFormatter()
14 formatter.formatOptions = [.withInternetDateTime]
15 formatter.timeZone = TimeZone(secondsFromGMT: 0)
16 return formatter
17 }()
18
19 private var currentUserKey: String? {
20 appState.currentUser?.canonicalName
21 }
22
23 private var isPinnedToHome: Bool {
24 _ = pinChangeCount
25 guard let currentUserKey else { return false }
26 return HomePinStore.isPinned(.user(user), for: currentUserKey, defaults: appState.accountDefaults)
27 }
28
29 var body: some View {
30 List {
31 if let avatarURL = user.avatar.flatMap(URL.init(string:)) {
32 Section {
33 HStack {
34 Spacer()
35 AsyncImage(url: avatarURL) { phase in
36 switch phase {
37 case .success(let image):
38 image
39 .resizable()
40 .scaledToFill()
41 case .failure, .empty:
42 Image(systemName: "person.crop.circle.fill")
43 .resizable()
44 .scaledToFit()
45 .foregroundStyle(.secondary)
46 .padding(20)
47 @unknown default:
48 EmptyView()
49 }
50 }
51 .frame(width: 96, height: 96)
52 .clipShape(Circle())
53 .overlay {
54 Circle()
55 .stroke(Color.secondary.opacity(0.2), lineWidth: 1)
56 }
57 Spacer()
58 }
59 .listRowBackground(isAMOLED ? Color.black : Color.clear)
60 }
61 }
62
63 Section {
64 LabeledContent("Username", value: user.username)
65 .themedRow()
66 LabeledContent("Canonical Name", value: user.canonicalName)
67 .themedRow()
68 if let userType = user.userType {
69 LabeledContent("User Type", value: userType)
70 .themedRow()
71 }
72 if let pronouns = user.pronouns {
73 LabeledContent("Pronouns", value: pronouns)
74 .themedRow()
75 }
76 if let suspensionNotice = user.suspensionNotice {
77 LabeledContent("Suspension Notice", value: suspensionNotice)
78 .themedRow()
79 }
80 }
81
82 Section {
83 LabeledContent("Email", value: user.email)
84 .themedRow()
85 if let urlString = user.url, let url = URL(string: urlString) {
86 LabeledContent("URL") {
87 Link(urlString, destination: url)
88 }
89 .themedRow()
90 }
91 if let location = user.location {
92 LabeledContent("Location", value: location)
93 .themedRow()
94 }
95 }
96
97 if let bio = user.bio, !bio.isEmpty {
98 Section("Bio") {
99 Text(profileBioAttributedString(bio))
100 .frame(maxWidth: .infinity, alignment: .leading)
101 .tint(.accentColor)
102 .textSelection(.enabled)
103 .themedRow()
104 }
105 }
106
107 if user.created != nil || user.updated != nil {
108 Section {
109 if let created = user.created {
110 LabeledContent("Joined", value: formattedTimestamp(created))
111 .themedRow()
112 }
113 if let updated = user.updated {
114 LabeledContent("Updated", value: formattedTimestamp(updated))
115 .themedRow()
116 }
117 }
118 }
119
120 if let viewModel = profileViewModel {
121 if contributionGraphsEnabled {
122 Section {
123 ContributionProfileCard(
124 actor: viewModel.actor,
125 weeks: viewModel.contributionCalendar.map {
126 ContributionCalendarLayout.weekColumns(from: $0.days)
127 } ?? [],
128 stats: viewModel.contributionStats,
129 isLoading: viewModel.isLoadingContributions,
130 error: viewModel.contributionsError ?? viewModel.contributionStatusText,
131 isIndexedButEmpty: viewModel.isContributionActivityIndexedButEmpty
132 )
133 .themedRow()
134 }
135 }
136
137 Section {
138 if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
139 ProgressView()
140 .themedRow()
141 } else if viewModel.repositories.isEmpty {
142 Text("No public repositories.")
143 .foregroundStyle(.secondary)
144 .themedRow()
145 } else {
146 ForEach(viewModel.repositories.prefix(4)) { repo in
147 NavigationLink {
148 RepositoryDetailView(repository: repo) { updatedRepository in
149 viewModel.updateRepository(updatedRepository)
150 }
151 } label: {
152 RepositoryRowView(repository: repo, buildStatus: .none)
153 }
154 }
155 .themedRow()
156 if viewModel.repositories.count > 4 {
157 NavigationLink("See All") {
158 UserRepositoriesView(viewModel: viewModel)
159 }
160 .themedRow()
161 }
162 }
163 } header: {
164 Text("Repositories")
165 }
166
167 Section {
168 if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty {
169 ProgressView()
170 .themedRow()
171 } else if viewModel.trackers.isEmpty {
172 Text("No public trackers.")
173 .foregroundStyle(.secondary)
174 .themedRow()
175 } else {
176 ForEach(viewModel.trackers.prefix(4)) { tracker in
177 NavigationLink {
178 TicketListView(tracker: tracker)
179 } label: {
180 UserProfileTrackerRowView(tracker: tracker)
181 }
182 }
183 .themedRow()
184 if viewModel.trackers.count > 4 {
185 NavigationLink("See All") {
186 UserTrackersView(viewModel: viewModel)
187 }
188 .themedRow()
189 }
190 }
191 } header: {
192 Text("Trackers")
193 }
194 }
195 }
196 .themedList()
197 .listStyle(.insetGrouped)
198 .navigationTitle(user.canonicalName)
199 .navigationBarTitleDisplayMode(.inline)
200 .toolbar {
201 if currentUserKey != nil {
202 ToolbarItem(placement: .topBarTrailing) {
203 Button {
204 togglePinnedState()
205 } label: {
206 Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
207 }
208 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
209 }
210 }
211 }
212 .task(id: user.canonicalName) {
213 let owner = user.canonicalName.hasPrefix("~")
214 ? String(user.canonicalName.dropFirst())
215 : user.canonicalName
216 let actor = user.canonicalName.hasPrefix("~") ? user.canonicalName : "~\(user.canonicalName)"
217
218 let vm: UserProfileViewModel
219 if let existingViewModel = profileViewModel,
220 existingViewModel.actor == actor,
221 existingViewModel.ownerUsername == owner {
222 vm = existingViewModel
223 } else {
224 let newViewModel = UserProfileViewModel(
225 ownerUsername: owner,
226 actor: actor,
227 client: appState.client,
228 statsService: HutchStatsService(
229 configuration: appState.configuration,
230 currentActor: appState.currentUser?.canonicalName
231 )
232 )
233 profileViewModel = newViewModel
234 vm = newViewModel
235 }
236
237 async let repos: () = vm.loadRepositories()
238 async let trackers: () = vm.loadTrackers()
239 if contributionGraphsEnabled {
240 async let contributions: () = vm.loadContributions()
241 _ = await (repos, trackers, contributions)
242 } else {
243 _ = await (repos, trackers)
244 }
245 }
246 }
247
248 private func togglePinnedState() {
249 guard let currentUserKey else { return }
250 HomePinStore.togglePin(.user(user), for: currentUserKey, defaults: appState.accountDefaults)
251 pinChangeCount += 1
252 }
253
254 private func formattedTimestamp(_ value: String) -> String {
255 guard let date = Self.iso8601Formatter.date(from: value) else {
256 return value
257 }
258
259 return date.formatted(date: .abbreviated, time: .shortened)
260 }
261}
262
263struct UserProfileTrackerRowView: View {
264 let tracker: TrackerSummary
265
266 var body: some View {
267 VStack(alignment: .leading, spacing: 4) {
268 HStack {
269 Text(tracker.name)
270 .font(.subheadline.weight(.medium))
271
272 Spacer()
273
274 VisibilityBadge(visibility: tracker.visibility)
275 }
276
277 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
278 Text("~\(owner)")
279 .font(.caption)
280 .foregroundStyle(.secondary)
281 }
282
283 if let description = tracker.description, !description.isEmpty {
284 Text(description)
285 .font(.caption)
286 .foregroundStyle(.secondary)
287 .lineLimit(2)
288 }
289
290 Text(tracker.updated.relativeDescription)
291 .font(.caption2)
292 .foregroundStyle(.tertiary)
293 }
294 .padding(.vertical, 2)
295 }
296}