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