krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.4: Hutch/Views/More/ProfileView.swift · raw
1import PhotosUI
2import SwiftUI
3
4private let profileBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
5 interpretedSyntax: .inlineOnlyPreservingWhitespace
6)
7
8struct ProfileView: View {
9 @Environment(AppState.self) private var appState
10 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
11 @State private var viewModel: SettingsViewModel?
12 @State private var contributionViewModel: UserProfileViewModel?
13 @State private var pendingDestructiveAction: ProfileDestructiveAction?
14
15 var body: some View {
16 Group {
17 if let viewModel {
18 profileContent(viewModel)
19 } else {
20 SRHTLoadingStateView(message: "Loading profile…")
21 }
22 }
23 .navigationTitle("Profile")
24 .task {
25 if viewModel == nil {
26 let vm = SettingsViewModel(client: appState.client)
27 viewModel = vm
28 await vm.loadProfile()
29 }
30 }
31 .task(id: contributionGraphsEnabled ? appState.currentUser?.canonicalName ?? "" : "contributions-disabled") {
32 guard contributionGraphsEnabled, let currentUser = appState.currentUser else { return }
33
34 let owner = currentUser.canonicalName.hasPrefix("~")
35 ? String(currentUser.canonicalName.dropFirst())
36 : currentUser.canonicalName
37 let actor = currentUser.canonicalName.hasPrefix("~") ? currentUser.canonicalName : "~\(currentUser.canonicalName)"
38
39 let vm: UserProfileViewModel
40 if let existingViewModel = contributionViewModel,
41 existingViewModel.actor == actor,
42 existingViewModel.ownerUsername == owner {
43 vm = existingViewModel
44 } else {
45 let newViewModel = UserProfileViewModel(
46 ownerUsername: owner,
47 actor: actor,
48 client: appState.client,
49 statsService: HutchStatsService(
50 configuration: appState.configuration,
51 currentActor: appState.currentUser?.canonicalName
52 )
53 )
54 contributionViewModel = newViewModel
55 vm = newViewModel
56 }
57
58 await vm.loadContributions()
59 }
60 }
61
62 @ViewBuilder
63 private func profileContent(_ viewModel: SettingsViewModel) -> some View {
64 @Bindable var vm = viewModel
65
66 Form {
67 if let profile = viewModel.profile {
68 profileSection(profile, viewModel: viewModel)
69
70 if contributionGraphsEnabled, let contributionViewModel {
71 Section {
72 ContributionProfileCard(
73 actor: contributionViewModel.actor,
74 weeks: contributionViewModel.contributionCalendar.map {
75 ContributionCalendarLayout.weekColumns(from: $0.days)
76 } ?? [],
77 stats: contributionViewModel.contributionStats,
78 isLoading: contributionViewModel.isLoadingContributions,
79 error: contributionViewModel.contributionsError ?? contributionViewModel.contributionStatusText,
80 isIndexedButEmpty: contributionViewModel.isContributionActivityIndexedButEmpty
81 )
82 }
83 }
84
85 sshKeysSection(viewModel)
86 pgpKeysSection(viewModel)
87 patSection(viewModel)
88 }
89 }
90 .themedList()
91 .overlay {
92 if viewModel.isLoading, viewModel.profile == nil {
93 SRHTLoadingStateView(message: "Loading profile…")
94 } else if let error = viewModel.error, viewModel.profile == nil {
95 SRHTErrorStateView(
96 title: "Couldn't Load Profile",
97 message: error,
98 retryAction: { await viewModel.loadProfile() }
99 )
100 }
101 }
102 .sheet(isPresented: $vm.isEditingProfile) {
103 if let profile = viewModel.profile {
104 EditProfileSheet(profile: profile, viewModel: viewModel)
105 }
106 }
107 .alert("Error", isPresented: Binding(
108 get: { viewModel.error != nil && viewModel.profile != nil },
109 set: { isPresented in
110 if !isPresented {
111 viewModel.error = nil
112 }
113 }
114 )) {
115 Button("OK") { viewModel.error = nil }
116 } message: {
117 if let error = viewModel.error {
118 Text(error)
119 }
120 }
121 .alert(
122 pendingDestructiveAction?.title ?? "",
123 isPresented: Binding(
124 get: { pendingDestructiveAction != nil },
125 set: { isPresented in
126 if !isPresented {
127 pendingDestructiveAction = nil
128 }
129 }
130 )
131 ) {
132 Button("Cancel", role: .cancel) {}
133 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
134 guard let action = pendingDestructiveAction else { return }
135 pendingDestructiveAction = nil
136 Task {
137 switch action {
138 case .deleteSSHKey(let key):
139 await viewModel.deleteSSHKey(key)
140 case .deletePGPKey(let key):
141 await viewModel.deletePGPKey(key)
142 }
143 }
144 }
145 } message: {
146 if let pendingDestructiveAction {
147 Text(pendingDestructiveAction.message)
148 }
149 }
150 .refreshable {
151 await viewModel.loadProfile()
152 }
153 }
154
155 @ViewBuilder
156 private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View {
157 Section("Profile") {
158 HStack(spacing: 12) {
159 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
160 switch phase {
161 case .success(let image):
162 image
163 .resizable()
164 .scaledToFill()
165 default:
166 Image(systemName: "person.crop.circle.fill")
167 .resizable()
168 .foregroundStyle(.secondary)
169 }
170 }
171 .frame(width: 56, height: 56)
172 .clipShape(Circle())
173
174 VStack(alignment: .leading, spacing: 2) {
175 Text(profile.canonicalName)
176 .font(.headline)
177 Text(profile.email)
178 .font(.subheadline)
179 .foregroundStyle(.secondary)
180 if let userType = profile.userType {
181 Text(userType.capitalized)
182 .font(.caption)
183 .foregroundStyle(.tertiary)
184 }
185 }
186 }
187 .padding(.vertical, 4)
188
189 if let bio = profile.bio, !bio.isEmpty {
190 VStack(alignment: .leading, spacing: 2) {
191 Text("Bio")
192 .font(.caption)
193 .foregroundStyle(.secondary)
194 ProfileBioView(markdown: bio)
195 }
196 }
197
198 if let location = profile.location, !location.isEmpty {
199 LabeledContent("Location", value: location)
200 }
201
202 if let url = profile.url, !url.isEmpty {
203 LabeledContent("URL", value: url)
204 }
205
206 if let status = profile.paymentStatus {
207 LabeledContent("Payment", value: status.capitalized)
208 }
209
210 if let sub = profile.subscription {
211 if let status = sub.status {
212 LabeledContent("Subscription", value: status.capitalized)
213 }
214 if let interval = sub.interval {
215 LabeledContent("Interval", value: interval.capitalized)
216 }
217 }
218
219 Button("Edit Profile") {
220 viewModel.isEditingProfile = true
221 }
222
223 SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) {
224 SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up")
225 }
226 }
227 }
228
229 @ViewBuilder
230 private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
231 @Bindable var vm = viewModel
232
233 Section {
234 ForEach(viewModel.sshKeys) { key in
235 VStack(alignment: .leading, spacing: 2) {
236 Text(key.fingerprint)
237 .font(.caption.monospaced())
238 .lineLimit(1)
239 .truncationMode(.middle)
240
241 HStack {
242 if let comment = key.comment, !comment.isEmpty {
243 Text(comment)
244 .font(.caption2)
245 .foregroundStyle(.secondary)
246 }
247 Spacer()
248 Text(key.created.relativeDescription)
249 .font(.caption2)
250 .foregroundStyle(.tertiary)
251 }
252
253 if let lastUsed = key.lastUsed {
254 Text("Last used \(lastUsed.relativeDescription)")
255 .font(.caption2)
256 .foregroundStyle(.tertiary)
257 }
258 }
259 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
260 Button("Delete", role: .destructive) {
261 pendingDestructiveAction = .deleteSSHKey(key)
262 }
263 }
264 }
265
266 if viewModel.isAddingSSHKey {
267 TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
268 .font(.caption.monospaced())
269 .lineLimit(3...6)
270
271 HStack {
272 Button("Cancel") {
273 viewModel.isAddingSSHKey = false
274 viewModel.newSSHKey = ""
275 }
276 Spacer()
277 Button("Add") {
278 Task { await viewModel.addSSHKey() }
279 }
280 .buttonStyle(.borderedProminent)
281 .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
282 }
283 } else {
284 Button {
285 viewModel.isAddingSSHKey = true
286 } label: {
287 SwiftUI.Label("Add SSH Key", systemImage: "key")
288 }
289 }
290 } header: {
291 Text("SSH Keys")
292 } footer: {
293 Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")")
294 }
295 }
296
297 @ViewBuilder
298 private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
299 @Bindable var vm = viewModel
300
301 Section {
302 ForEach(viewModel.pgpKeys) { key in
303 VStack(alignment: .leading, spacing: 2) {
304 Text(key.fingerprint)
305 .font(.caption.monospaced())
306 .lineLimit(1)
307 .truncationMode(.middle)
308
309 Text(key.created.relativeDescription)
310 .font(.caption2)
311 .foregroundStyle(.tertiary)
312 }
313 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
314 Button("Delete", role: .destructive) {
315 pendingDestructiveAction = .deletePGPKey(key)
316 }
317 }
318 }
319
320 if viewModel.isAddingPGPKey {
321 TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
322 .font(.caption.monospaced())
323 .lineLimit(3...6)
324
325 HStack {
326 Button("Cancel") {
327 viewModel.isAddingPGPKey = false
328 viewModel.newPGPKey = ""
329 }
330 Spacer()
331 Button("Add") {
332 Task { await viewModel.addPGPKey() }
333 }
334 .buttonStyle(.borderedProminent)
335 .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
336 }
337 } else {
338 Button {
339 viewModel.isAddingPGPKey = true
340 } label: {
341 SwiftUI.Label("Add PGP Key", systemImage: "key.fill")
342 }
343 }
344 } header: {
345 Text("PGP Keys")
346 } footer: {
347 Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")")
348 }
349 }
350
351 @ViewBuilder
352 private func patSection(_ viewModel: SettingsViewModel) -> some View {
353 Section {
354 if viewModel.isLoadingPATs {
355 HStack {
356 Spacer()
357 ProgressView()
358 Spacer()
359 }
360 } else if viewModel.personalAccessTokens.isEmpty {
361 Button("Load Tokens") {
362 Task { await viewModel.loadPersonalAccessTokens() }
363 }
364 } else {
365 ForEach(viewModel.personalAccessTokens) { token in
366 VStack(alignment: .leading, spacing: 4) {
367 HStack {
368 Text(token.comment ?? "Token #\(token.id)")
369 .font(.subheadline)
370 Spacer()
371 }
372
373 HStack(spacing: 12) {
374 Text("Issued \(token.issued.relativeDescription)")
375 .font(.caption2)
376 .foregroundStyle(.secondary)
377
378 if let expires = token.expires {
379 Text("Expires \(expires.relativeDescription)")
380 .font(.caption2)
381 .foregroundStyle(expires < Date.now ? .red : .secondary)
382 }
383 }
384
385 if let grants = token.grants, !grants.isEmpty {
386 Text(grants)
387 .font(.caption2.monospaced())
388 .foregroundStyle(.tertiary)
389 .lineLimit(2)
390 }
391 }
392 }
393 }
394 } header: {
395 Text("Personal Access Tokens")
396 } footer: {
397 if !viewModel.personalAccessTokens.isEmpty {
398 Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")")
399 }
400 }
401 }
402}
403
404private struct ProfileBioView: View {
405 let markdown: String
406
407 var body: some View {
408 Text(profileBioAttributedString(markdown))
409 .frame(maxWidth: .infinity, alignment: .leading)
410 .tint(.accentColor)
411 .textSelection(.enabled)
412 }
413}
414
415func profileBioAttributedString(_ markdown: String) -> AttributedString {
416 guard let attributed = try? AttributedString(
417 markdown: markdown,
418 options: profileBioMarkdownOptions
419 ) else {
420 return AttributedString(markdown)
421 }
422 return attributed
423}
424
425private struct EditProfileSheet: View {
426 let profile: UserProfile
427 let viewModel: SettingsViewModel
428
429 @State private var email: String
430 @State private var url: String
431 @State private var location: String
432 @State private var bio: String
433 @State private var selectedPhoto: PhotosPickerItem?
434 @State private var avatarPreview: UIImage?
435 @State private var isShowingRemoveAvatarConfirmation = false
436
437 @Environment(\.dismiss) private var dismiss
438
439 init(profile: UserProfile, viewModel: SettingsViewModel) {
440 self.profile = profile
441 self.viewModel = viewModel
442 _email = State(initialValue: profile.email)
443 _url = State(initialValue: profile.url ?? "")
444 _location = State(initialValue: profile.location ?? "")
445 _bio = State(initialValue: profile.bio ?? "")
446 }
447
448 var body: some View {
449 NavigationStack {
450 Form {
451 Section {
452 HStack {
453 Spacer()
454 VStack(spacing: 8) {
455 PhotosPicker(selection: $selectedPhoto, matching: .images) {
456 Group {
457 if let avatarPreview {
458 Image(uiImage: avatarPreview)
459 .resizable()
460 .scaledToFill()
461 } else {
462 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
463 switch phase {
464 case .success(let image):
465 image
466 .resizable()
467 .scaledToFill()
468 default:
469 Image(systemName: "person.crop.circle.fill")
470 .resizable()
471 .foregroundStyle(.secondary)
472 }
473 }
474 }
475 }
476 .frame(width: 80, height: 80)
477 .clipShape(Circle())
478 .overlay(
479 Circle()
480 .stroke(.secondary.opacity(0.3), lineWidth: 1)
481 )
482 }
483
484 Text("Tap to change avatar")
485 .font(.caption)
486 .foregroundStyle(.secondary)
487
488 if viewModel.isUploadingAvatar {
489 ProgressView()
490 .controlSize(.small)
491 }
492 }
493 Spacer()
494 }
495 .listRowBackground(Color.clear)
496
497 if profile.avatar != nil || avatarPreview != nil {
498 HStack {
499 Spacer()
500 Button(role: .destructive) {
501 isShowingRemoveAvatarConfirmation = true
502 } label: {
503 Text("Remove Avatar")
504 }
505 .buttonStyle(.borderedProminent)
506 .disabled(viewModel.isUploadingAvatar)
507 Spacer()
508 }
509 .listRowBackground(Color.clear)
510 .listRowSeparator(.hidden)
511 }
512 }
513
514 Section("Edit Profile") {
515 VStack(alignment: .leading, spacing: 4) {
516 Text("Email")
517 .font(.caption)
518 .foregroundStyle(.secondary)
519 TextField("Enter email", text: $email)
520 .textContentType(.emailAddress)
521 .keyboardType(.emailAddress)
522 .autocorrectionDisabled()
523 .textInputAutocapitalization(.never)
524 }
525
526 VStack(alignment: .leading, spacing: 4) {
527 Text("URL")
528 .font(.caption)
529 .foregroundStyle(.secondary)
530 TextField("Enter URL", text: $url)
531 .textContentType(.URL)
532 .keyboardType(.URL)
533 .autocorrectionDisabled()
534 .textInputAutocapitalization(.never)
535 }
536
537 VStack(alignment: .leading, spacing: 4) {
538 Text("Location")
539 .font(.caption)
540 .foregroundStyle(.secondary)
541 TextField("Enter location", text: $location)
542 }
543
544 VStack(alignment: .leading, spacing: 4) {
545 Text("Bio")
546 .font(.caption)
547 .foregroundStyle(.secondary)
548 TextField("Enter bio", text: $bio, axis: .vertical)
549 .lineLimit(3...6)
550 }
551 }
552 }
553 .navigationTitle("Edit Profile")
554 .navigationBarTitleDisplayMode(.inline)
555 .onChange(of: selectedPhoto) { _, newItem in
556 guard let newItem else { return }
557 Task {
558 if let data = try? await newItem.loadTransferable(type: Data.self),
559 let image = UIImage(data: data) {
560 avatarPreview = image
561 if let jpegData = image.jpegData(compressionQuality: 0.85) {
562 await viewModel.uploadAvatar(jpegData: jpegData)
563 }
564 }
565 }
566 }
567 .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
568 Button("Cancel", role: .cancel) {}
569 Button("Remove Avatar", role: .destructive) {
570 Task {
571 await viewModel.removeAvatar()
572 if viewModel.error == nil {
573 avatarPreview = nil
574 selectedPhoto = nil
575 }
576 }
577 }
578 } message: {
579 Text("Your profile avatar will be removed from SourceHut.")
580 }
581 .toolbar {
582 ToolbarItem(placement: .cancellationAction) {
583 Button("Cancel") {
584 dismiss()
585 }
586 }
587 ToolbarItem(placement: .confirmationAction) {
588 Button {
589 Task {
590 await viewModel.saveProfile(
591 email: email,
592 url: url,
593 location: location,
594 bio: bio
595 )
596 if viewModel.error == nil {
597 dismiss()
598 }
599 }
600 } label: {
601 if viewModel.isSavingProfile {
602 ProgressView()
603 .controlSize(.small)
604 } else {
605 Text("Save")
606 }
607 }
608 .disabled(viewModel.isSavingProfile)
609 }
610 }
611 }
612 }
613}
614
615private enum ProfileDestructiveAction {
616 case deleteSSHKey(SSHKey)
617 case deletePGPKey(PGPKey)
618
619 var title: String {
620 switch self {
621 case .deleteSSHKey:
622 "Remove SSH Key?"
623 case .deletePGPKey:
624 "Remove PGP Key?"
625 }
626 }
627
628 var confirmationLabel: String {
629 switch self {
630 case .deleteSSHKey:
631 "Remove SSH Key"
632 case .deletePGPKey:
633 "Remove PGP Key"
634 }
635 }
636
637 var message: String {
638 switch self {
639 case .deleteSSHKey(let key):
640 "Remove SSH key \(key.fingerprint) from your account?"
641 case .deletePGPKey(let key):
642 "Remove PGP key \(key.fingerprint) from your account?"
643 }
644 }
645}