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