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