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}