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