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