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}