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}