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